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.
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/PKG-INFO +1 -1
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/configuration.py +15 -2
- launchdarkly_observability-1.2.4/ldobserve/_otel/logging_handler.py +97 -0
- launchdarkly_observability-1.2.4/ldobserve/_otel/test_logging_handler.py +105 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/pyproject.toml +1 -1
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/README.md +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/__init__.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/__init__.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/__init__.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/async_base_client.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/base_model.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/base_operation.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/client.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_fields.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_mutations.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_queries.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/custom_typing_fields.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/enums.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/exceptions.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/fragments.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/get_sampling_config.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/generated/public_graph_client/input_types.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/get_sampling_config.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/operators/query.gql +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/__init__.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/__init__.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/custom_sampler.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/log-test-scenarios.json +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/span-test-scenarios.json +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/test_custom_sampler.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/test_sample_logs.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/_sampling/test_sample_spans.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/sampling_log_exporter.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/sampling_trace_exporter.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/__init__.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/dict.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/test_dict.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/config.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/observe.py +0 -0
- {launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/test_observe_instance.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
{launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_graph/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_otel/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/__init__.py
RENAMED
|
File without changes
|
{launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/dict.py
RENAMED
|
File without changes
|
{launchdarkly_observability-1.2.2 → launchdarkly_observability-1.2.4}/ldobserve/_util/test_dict.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|