launchdarkly-observability 1.2.2__tar.gz → 1.2.4__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.
Files changed (40) hide show
  1. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/PKG-INFO +1 -1
  2. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/configuration.py +15 -2
  3. launchdarkly_observability-1.2.4/ldobserve/_otel/logging_handler.py +97 -0
  4. launchdarkly_observability-1.2.4/ldobserve/_otel/test_logging_handler.py +105 -0
  5. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/pyproject.toml +1 -1
  6. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/README.md +0 -0
  7. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/__init__.py +0 -0
  8. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/__init__.py +0 -0
  9. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/__init__.py +0 -0
  10. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/async_base_client.py +0 -0
  11. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/base_model.py +0 -0
  12. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/base_operation.py +0 -0
  13. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/client.py +0 -0
  14. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_fields.py +0 -0
  15. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_mutations.py +0 -0
  16. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_queries.py +0 -0
  17. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_typing_fields.py +0 -0
  18. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/enums.py +0 -0
  19. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/exceptions.py +0 -0
  20. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/fragments.py +0 -0
  21. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/get_sampling_config.py +0 -0
  22. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/input_types.py +0 -0
  23. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/get_sampling_config.py +0 -0
  24. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/operators/query.gql +0 -0
  25. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/__init__.py +0 -0
  26. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/__init__.py +0 -0
  27. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/custom_sampler.py +0 -0
  28. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/log-test-scenarios.json +0 -0
  29. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/span-test-scenarios.json +0 -0
  30. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/test_custom_sampler.py +0 -0
  31. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/test_sample_logs.py +0 -0
  32. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/test_sample_spans.py +0 -0
  33. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/sampling_log_exporter.py +0 -0
  34. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/sampling_trace_exporter.py +0 -0
  35. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/__init__.py +0 -0
  36. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/dict.py +0 -0
  37. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/test_dict.py +0 -0
  38. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/config.py +0 -0
  39. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/observe.py +0 -0
  40. {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/test_observe_instance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: launchdarkly-observability
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: LaunchDarkly Observability for Python
5
5
  License-Expression: Apache-2.0
6
6
  Author: LaunchDarkly Authors
@@ -29,6 +29,10 @@ from opentelemetry.sdk.metrics import (
29
29
  )
30
30
 
31
31
  from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
32
+ from ldobserve._otel.logging_handler import (
33
+ LDLoggingHandler,
34
+ install_on_root_logger,
35
+ )
32
36
  from opentelemetry.sdk.resources import Resource
33
37
  from opentelemetry.sdk.trace import TracerProvider
34
38
  import opentelemetry._logs as _logs
@@ -132,7 +136,12 @@ class _OTELConfiguration:
132
136
 
133
137
  # The log handler is always created even if logging is not instrumented.
134
138
  # This allows directly logging to OpenTelemetry.
135
- self._log_handler = LoggingHandler(
139
+ #
140
+ # LDLoggingHandler is a hardened LoggingHandler: it is safe to install on
141
+ # the root logger alongside other logging instrumentation (e.g. the New
142
+ # Relic agent) because it guards against re-entrancy and never forwards
143
+ # non-serializable record attributes. See logging_handler.py.
144
+ self._log_handler = LDLoggingHandler(
136
145
  level=self._config.log_level, logger_provider=self._logger_provider
137
146
  )
138
147
 
@@ -158,7 +167,11 @@ class _OTELConfiguration:
158
167
  # If log_correlation is false, then the LoggingInstrumentor will not
159
168
  # configure basic logging. If it does, then this call has no effect.
160
169
  logging.basicConfig(level=self._config.log_level)
161
- logging.getLogger().addHandler(self._log_handler)
170
+ # Registration is idempotent: if the SDK is initialized more than
171
+ # once in the same process (e.g. a repeated set_config, a test suite,
172
+ # or a gunicorn preload + worker) this never stacks duplicate handlers
173
+ # on the root logger, which would double-export every log record.
174
+ install_on_root_logger(self._log_handler)
162
175
 
163
176
  metric_reader = PeriodicExportingMetricReader(
164
177
  exporter=OTLPMetricExporter(
@@ -0,0 +1,97 @@
1
+ """A hardened OpenTelemetry logging handler.
2
+
3
+ The SDK attaches a :class:`~opentelemetry.sdk._logs.LoggingHandler` to the root
4
+ logger so that application logs are exported to LaunchDarkly. Because the handler
5
+ lives on the root logger it sees *every* log record in the process, including
6
+ records produced by other logging instrumentation (the New Relic agent, Datadog,
7
+ Sentry, ...) and records produced synchronously by our own export pipeline.
8
+
9
+ Two failure modes follow from that, both of which this handler defends against:
10
+
11
+ 1. **Foreign record attributes.** Other logging instrumentation stashes
12
+ arbitrary objects on the :class:`logging.LogRecord` (for example the New Relic
13
+ agent leaves ``record._nr_original_message``, a function wrapper). When the
14
+ stock handler forwards those attributes, OpenTelemetry's attribute validation
15
+ rejects the non-serializable value and emits its own ``WARNING`` log. If
16
+ another agent re-dispatches that warning back through the root logger, it is
17
+ re-captured here, producing a new warning, and so on -- an unbounded feedback
18
+ loop. We drop attribute values that are not valid OpenTelemetry types before
19
+ they ever reach validation.
20
+
21
+ 2. **Re-entrancy.** Any log emitted *while we are inside* ``emit`` (an attribute
22
+ warning, an exporter error, a gRPC log) must not be captured again by this
23
+ handler, or a single application log can recurse. A context-local guard makes
24
+ re-entrant calls a no-op.
25
+
26
+ Together these make the handler safe to run alongside any other logging
27
+ instrumentation without disabling log capture.
28
+ """
29
+
30
+ import contextvars
31
+ import logging
32
+ import typing
33
+
34
+ from opentelemetry.sdk._logs import LoggingHandler
35
+
36
+ # Set while this handler is emitting a record. Any log produced synchronously by
37
+ # the emit/export pipeline re-enters ``emit`` with this flag set and is dropped,
38
+ # which breaks otherwise-unbounded recursion. A module-level ContextVar (rather
39
+ # than per-instance state) also guards the case where more than one LD handler
40
+ # ends up on the root logger.
41
+ _in_emit: contextvars.ContextVar[bool] = contextvars.ContextVar(
42
+ "ldobserve_in_emit", default=False
43
+ )
44
+
45
+ # Types OpenTelemetry accepts as attribute values (or sequences thereof).
46
+ _VALID_ATTRIBUTE_TYPES = (bool, bytes, int, float, str)
47
+
48
+
49
+ def _is_valid_attribute_value(value: typing.Any) -> bool:
50
+ if isinstance(value, _VALID_ATTRIBUTE_TYPES):
51
+ return True
52
+ if isinstance(value, (list, tuple)):
53
+ return all(isinstance(item, _VALID_ATTRIBUTE_TYPES) for item in value)
54
+ return False
55
+
56
+
57
+ class LDLoggingHandler(LoggingHandler):
58
+ """An :class:`~opentelemetry.sdk._logs.LoggingHandler` that is safe to
59
+ install on the root logger alongside other logging instrumentation."""
60
+
61
+ def emit(self, record: logging.LogRecord) -> None:
62
+ # Re-entrancy guard: drop records produced while we are already emitting.
63
+ if _in_emit.get():
64
+ return
65
+ token = _in_emit.set(True)
66
+ try:
67
+ super().emit(record)
68
+ finally:
69
+ _in_emit.reset(token)
70
+
71
+ def _get_attributes(
72
+ self, record: logging.LogRecord
73
+ ) -> typing.Dict[str, typing.Any]:
74
+ # Never forward foreign / non-serializable record attributes (e.g. the
75
+ # New Relic agent's ``_nr_original_message`` function wrapper); they would
76
+ # fail OpenTelemetry's attribute validation and trigger a warning that can
77
+ # feed back into the root logger.
78
+ attributes = super()._get_attributes(record)
79
+ return {
80
+ key: value
81
+ for key, value in attributes.items()
82
+ if _is_valid_attribute_value(value)
83
+ }
84
+
85
+
86
+ def install_on_root_logger(handler: LDLoggingHandler) -> None:
87
+ """Install ``handler`` on the root logger, idempotently.
88
+
89
+ Any previously installed :class:`LDLoggingHandler` is removed first, so that
90
+ repeated SDK initialization in the same process never stacks duplicate
91
+ handlers on the root logger (which would double-export every log record).
92
+ """
93
+ root_logger = logging.getLogger()
94
+ for existing in list(root_logger.handlers):
95
+ if isinstance(existing, LDLoggingHandler):
96
+ root_logger.removeHandler(existing)
97
+ root_logger.addHandler(handler)
@@ -0,0 +1,105 @@
1
+ import logging
2
+
3
+ import pytest
4
+ from opentelemetry.sdk._logs import LoggerProvider
5
+ from opentelemetry.sdk._logs.export import (
6
+ InMemoryLogExporter,
7
+ SimpleLogRecordProcessor,
8
+ )
9
+
10
+ from ldobserve._otel.logging_handler import (
11
+ LDLoggingHandler,
12
+ install_on_root_logger,
13
+ )
14
+
15
+
16
+ def _make_handler(provider: LoggerProvider) -> LDLoggingHandler:
17
+ return LDLoggingHandler(level=logging.NOTSET, logger_provider=provider)
18
+
19
+
20
+ def test_get_attributes_drops_non_serializable_values():
21
+ """Foreign / non-serializable record attributes (e.g. the New Relic agent's
22
+ ``_nr_original_message`` function wrapper) must not be forwarded -- they fail
23
+ OpenTelemetry attribute validation and seed a logging feedback loop."""
24
+ handler = _make_handler(LoggerProvider())
25
+
26
+ record = logging.LogRecord(
27
+ name="app",
28
+ level=logging.WARNING,
29
+ pathname=__file__,
30
+ lineno=1,
31
+ msg="hello",
32
+ args=None,
33
+ exc_info=None,
34
+ )
35
+ # mimic the attributes a coexisting logging instrumentation leaves behind
36
+ record._nr_original_message = lambda: "wrapped" # function wrapper
37
+ record.some_object = object()
38
+ record.ok_str = "value"
39
+ record.ok_int = 7
40
+ record.ok_seq = ["a", "b"]
41
+
42
+ attributes = handler._get_attributes(record)
43
+
44
+ assert "_nr_original_message" not in attributes
45
+ assert "some_object" not in attributes
46
+ assert attributes["ok_str"] == "value"
47
+ assert attributes["ok_int"] == 7
48
+ assert attributes["ok_seq"] == ["a", "b"]
49
+
50
+
51
+ def test_emit_is_not_reentrant():
52
+ """A log produced synchronously by the export pipeline must not be captured
53
+ again by the handler, otherwise a single application log recurses."""
54
+ exporter = InMemoryLogExporter()
55
+ provider = LoggerProvider()
56
+ provider.add_log_record_processor(SimpleLogRecordProcessor(exporter))
57
+ handler = _make_handler(provider)
58
+
59
+ export_calls = {"count": 0}
60
+ original_export = exporter.export
61
+
62
+ def logging_export(batch):
63
+ # Simulate a pipeline component that logs synchronously during export
64
+ # (the real OTLP exporter does exactly this on connection errors).
65
+ export_calls["count"] += 1
66
+ logging.getLogger("pipeline.exporter").warning(
67
+ "synchronous export log %d", export_calls["count"]
68
+ )
69
+ return original_export(batch)
70
+
71
+ exporter.export = logging_export
72
+
73
+ root = logging.getLogger()
74
+ install_on_root_logger(handler)
75
+ old_level = root.level
76
+ root.setLevel(logging.DEBUG)
77
+ try:
78
+ logging.getLogger("app").warning("application log line")
79
+ finally:
80
+ root.setLevel(old_level)
81
+ root.removeHandler(handler)
82
+
83
+ # Without the re-entrancy guard the synchronous export log re-enters emit and
84
+ # recurses unboundedly. With it, only the bounded set of distinct records is
85
+ # exported. The exact count is small and finite -- never a runaway loop.
86
+ assert export_calls["count"] <= 3
87
+ assert exporter.export.__name__ == "logging_export"
88
+
89
+
90
+ def test_install_on_root_logger_is_idempotent():
91
+ """Repeated installation must not stack duplicate LD handlers on the root
92
+ logger (which would double-export every record)."""
93
+ root = logging.getLogger()
94
+ before = [h for h in root.handlers if isinstance(h, LDLoggingHandler)]
95
+ handlers = [_make_handler(LoggerProvider()) for _ in range(3)]
96
+ try:
97
+ for handler in handlers:
98
+ install_on_root_logger(handler)
99
+ ld_handlers = [h for h in root.handlers if isinstance(h, LDLoggingHandler)]
100
+ # exactly one LD handler remains -- the most recently installed one
101
+ assert len(ld_handlers) == len(before) + 1
102
+ assert ld_handlers[-1] is handlers[-1]
103
+ finally:
104
+ for handler in handlers:
105
+ root.removeHandler(handler)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "launchdarkly-observability"
3
- version = "1.2.2"
3
+ version = "1.2.4"
4
4
  description = "LaunchDarkly Observability for Python"
5
5
  license = "Apache-2.0"
6
6
  authors = [