solnlib 8.1.1__tar.gz → 8.2.0b1__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.
- {solnlib-8.1.1 → solnlib-8.2.0b1}/PKG-INFO +1 -1
- {solnlib-8.1.1 → solnlib-8.2.0b1}/pyproject.toml +1 -1
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/__init__.py +1 -1
- solnlib-8.2.0b1/solnlib/_observability_flush.py +161 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/observability.py +64 -31
- {solnlib-8.1.1 → solnlib-8.2.0b1}/LICENSE +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/_settings.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/_utils.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/acl.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/alerts_rest_client.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/bulletin_rest_client.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/concurrent/concurrent_executor.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/concurrent/process_pool.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/concurrent/thread_pool.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/conf_manager.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/credentials.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/file_monitor.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/hec_config.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/log.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/__init__.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/checkpointer.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/event.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/event_writer.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/modinput.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/modular_input.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/net_utils.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/orphan_process_monitor.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/pattern.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/rest.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/schedule/job.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/schedule/scheduler.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/server_info.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/soln_exceptions.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/splunk_rest_client.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/splunkenv.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/time_parser.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/timer_queue.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/user_access.py +0 -0
- {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/utils.py +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Splunk Inc.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
#
|
|
16
|
+
"""Private background worker for OpenTelemetry metric flush and shutdown."""
|
|
17
|
+
|
|
18
|
+
import atexit
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
_EXIT_GRACE_SECONDS = 5.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _remaining_ms(deadline: float) -> int:
|
|
28
|
+
return max(0, int((deadline - time.monotonic()) * 1000))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _AsyncFlushController:
|
|
32
|
+
"""Serialize one provider's explicit flushes and final shutdown."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, provider, logger, format_error: Callable[[BaseException], str]
|
|
36
|
+
) -> None:
|
|
37
|
+
self._provider = provider
|
|
38
|
+
self._logger = logger
|
|
39
|
+
self._format_error = format_error
|
|
40
|
+
self._condition = threading.Condition()
|
|
41
|
+
self._pending_timeout = None
|
|
42
|
+
self._shutdown_deadline = None
|
|
43
|
+
self._worker = threading.Thread(
|
|
44
|
+
target=self._run, name="SolnlibMetricFlush", daemon=True
|
|
45
|
+
)
|
|
46
|
+
self._worker.start()
|
|
47
|
+
|
|
48
|
+
def request_flush(self, timeout_millis: float) -> None:
|
|
49
|
+
with self._condition:
|
|
50
|
+
if self._shutdown_deadline is None:
|
|
51
|
+
self._pending_timeout = int(timeout_millis)
|
|
52
|
+
self._condition.notify()
|
|
53
|
+
|
|
54
|
+
def request_shutdown(self, deadline: float) -> None:
|
|
55
|
+
with self._condition:
|
|
56
|
+
if self._shutdown_deadline is None:
|
|
57
|
+
self._shutdown_deadline = deadline
|
|
58
|
+
self._condition.notify()
|
|
59
|
+
|
|
60
|
+
def join_until(self, deadline: float) -> None:
|
|
61
|
+
self._worker.join(timeout=max(0.0, deadline - time.monotonic()))
|
|
62
|
+
|
|
63
|
+
def _run(self) -> None:
|
|
64
|
+
while True:
|
|
65
|
+
flush_timeout, shutdown_deadline = self._wait_for_request()
|
|
66
|
+
if shutdown_deadline is None:
|
|
67
|
+
if flush_timeout is not None:
|
|
68
|
+
self._flush(flush_timeout)
|
|
69
|
+
continue
|
|
70
|
+
self._shut_down(flush_timeout, shutdown_deadline)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
def _has_request(self) -> bool:
|
|
74
|
+
return self._pending_timeout is not None or self._shutdown_deadline is not None
|
|
75
|
+
|
|
76
|
+
def _wait_for_request(self) -> tuple[Optional[int], Optional[float]]:
|
|
77
|
+
with self._condition:
|
|
78
|
+
self._condition.wait_for(self._has_request)
|
|
79
|
+
flush_timeout, self._pending_timeout = self._pending_timeout, None
|
|
80
|
+
return flush_timeout, self._shutdown_deadline
|
|
81
|
+
|
|
82
|
+
def _shut_down(self, flush_timeout: Optional[int], deadline: float) -> None:
|
|
83
|
+
# Readers with an infinite export interval do not collect on shutdown.
|
|
84
|
+
remaining = _remaining_ms(deadline)
|
|
85
|
+
if flush_timeout is not None and remaining:
|
|
86
|
+
self._flush(min(flush_timeout, remaining))
|
|
87
|
+
try:
|
|
88
|
+
self._provider.shutdown(timeout_millis=_remaining_ms(deadline))
|
|
89
|
+
except Exception as error:
|
|
90
|
+
self._logger.info(
|
|
91
|
+
"Failed to shut down metrics: %s", self._format_error(error)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _flush(self, timeout_ms: int) -> None:
|
|
95
|
+
try:
|
|
96
|
+
self._provider.force_flush(timeout_millis=timeout_ms)
|
|
97
|
+
except Exception as error:
|
|
98
|
+
self._logger.info("Failed to flush metrics: %s", self._format_error(error))
|
|
99
|
+
|
|
100
|
+
def restart_after_fork(self) -> None:
|
|
101
|
+
"""Replace parent-thread state after fork in the child process."""
|
|
102
|
+
self._condition = threading.Condition()
|
|
103
|
+
self._pending_timeout = None
|
|
104
|
+
self._shutdown_deadline = None
|
|
105
|
+
self._worker = threading.Thread(
|
|
106
|
+
target=self._run, name="SolnlibMetricFlush", daemon=True
|
|
107
|
+
)
|
|
108
|
+
self._worker.start()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _FlushRegistry:
|
|
112
|
+
"""Coordinate all metric providers against one process-exit deadline."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self._lock = threading.Lock()
|
|
116
|
+
self._controllers = []
|
|
117
|
+
self._registered = False
|
|
118
|
+
self._closed = False
|
|
119
|
+
|
|
120
|
+
def register(self, provider, logger, format_error) -> _AsyncFlushController:
|
|
121
|
+
with self._lock:
|
|
122
|
+
if self._closed:
|
|
123
|
+
raise RuntimeError("Metrics shutdown has started")
|
|
124
|
+
if not self._registered:
|
|
125
|
+
atexit.register(_shutdown_all)
|
|
126
|
+
if hasattr(os, "register_at_fork"):
|
|
127
|
+
os.register_at_fork(after_in_child=self._after_fork_child)
|
|
128
|
+
self._registered = True
|
|
129
|
+
controller = _AsyncFlushController(provider, logger, format_error)
|
|
130
|
+
self._controllers.append(controller)
|
|
131
|
+
return controller
|
|
132
|
+
|
|
133
|
+
def shutdown_all(self) -> None:
|
|
134
|
+
deadline = time.monotonic() + _EXIT_GRACE_SECONDS
|
|
135
|
+
with self._lock:
|
|
136
|
+
if self._closed:
|
|
137
|
+
return
|
|
138
|
+
self._closed = True
|
|
139
|
+
controllers = tuple(self._controllers)
|
|
140
|
+
for controller in controllers:
|
|
141
|
+
controller.request_shutdown(deadline)
|
|
142
|
+
for controller in controllers:
|
|
143
|
+
controller.join_until(deadline)
|
|
144
|
+
|
|
145
|
+
def _after_fork_child(self) -> None:
|
|
146
|
+
self._lock = threading.Lock()
|
|
147
|
+
if self._closed:
|
|
148
|
+
return
|
|
149
|
+
for controller in self._controllers:
|
|
150
|
+
controller.restart_after_fork()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_registry = _FlushRegistry()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _register_controller(provider, logger, format_error) -> _AsyncFlushController:
|
|
157
|
+
return _registry.register(provider, logger, format_error)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _shutdown_all() -> None:
|
|
161
|
+
_registry.shutdown_all()
|
|
@@ -31,8 +31,8 @@ This module provides three public components:
|
|
|
31
31
|
- :class:`StanzaObservabilityRecorder` — a stanza-scoped recorder that wraps
|
|
32
32
|
``ObservabilityService`` with a per-process singleton cache. Bind it to a
|
|
33
33
|
single stanza name and call :meth:`~StanzaObservabilityRecorder.record` after
|
|
34
|
-
each batch of ingested events. Use as a context manager
|
|
35
|
-
on exit.
|
|
34
|
+
each batch of ingested events. Use as a context manager to schedule a
|
|
35
|
+
background flush on exit.
|
|
36
36
|
|
|
37
37
|
Typical usage::
|
|
38
38
|
|
|
@@ -52,6 +52,7 @@ import ssl
|
|
|
52
52
|
import threading
|
|
53
53
|
import urllib.request
|
|
54
54
|
from typing import Callable, ClassVar, Optional, Union
|
|
55
|
+
from ._observability_flush import _register_controller
|
|
55
56
|
from .splunkenv import get_conf_stanzas
|
|
56
57
|
from opentelemetry.metrics import Instrument, Meter
|
|
57
58
|
from opentelemetry.sdk.metrics import MeterProvider, Counter, Histogram
|
|
@@ -410,10 +411,12 @@ class ObservabilityService:
|
|
|
410
411
|
self.event_bytes_counter: Optional[Counter] = None
|
|
411
412
|
self._meter: Optional[Meter] = None
|
|
412
413
|
self._provider: Optional[MeterProvider] = None
|
|
414
|
+
self._flush_controller = None
|
|
413
415
|
|
|
414
416
|
for logger_name in _METRICS_SDK_LOGGERS:
|
|
415
417
|
logging.getLogger(logger_name).addFilter(_downgrade_to_info_filter)
|
|
416
418
|
|
|
419
|
+
provider = None
|
|
417
420
|
try:
|
|
418
421
|
if type(modinput_type) is not str or not _is_safe_identifier_str(
|
|
419
422
|
modinput_type
|
|
@@ -451,34 +454,54 @@ class ObservabilityService:
|
|
|
451
454
|
for exporter in extra_exporters or []:
|
|
452
455
|
metric_readers.append(PeriodicExportingMetricReader(exporter))
|
|
453
456
|
|
|
454
|
-
|
|
455
|
-
resource=resource,
|
|
457
|
+
provider = MeterProvider(
|
|
458
|
+
resource=resource,
|
|
459
|
+
metric_readers=metric_readers,
|
|
460
|
+
shutdown_on_exit=False,
|
|
456
461
|
)
|
|
457
|
-
|
|
462
|
+
meter = provider.get_meter(ta_name, ta_version)
|
|
458
463
|
|
|
459
|
-
|
|
464
|
+
event_count_counter = meter.create_counter(
|
|
460
465
|
name="splunk.addon.events",
|
|
461
466
|
description="Number of events ingested by the add-on modular input",
|
|
462
467
|
unit="1",
|
|
463
468
|
)
|
|
464
|
-
|
|
469
|
+
event_bytes_counter = meter.create_counter(
|
|
465
470
|
name="splunk.addon.events.bytes",
|
|
466
471
|
description="Volume of data ingested by the add-on modular input",
|
|
467
472
|
unit="By",
|
|
468
473
|
)
|
|
469
474
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
"modinput_type=%s",
|
|
473
|
-
ta_name,
|
|
474
|
-
ta_version,
|
|
475
|
-
modinput_type,
|
|
475
|
+
controller = _register_controller(
|
|
476
|
+
provider, self._logger, _safe_exception_str
|
|
476
477
|
)
|
|
477
478
|
except Exception as error:
|
|
479
|
+
if provider is not None:
|
|
480
|
+
try:
|
|
481
|
+
provider.shutdown()
|
|
482
|
+
except Exception as cleanup_error:
|
|
483
|
+
self._logger.info(
|
|
484
|
+
"Failed to clean up metrics after init failure: %s",
|
|
485
|
+
_safe_exception_str(cleanup_error),
|
|
486
|
+
)
|
|
478
487
|
self._logger.info(
|
|
479
488
|
"Failed to initialise ObservabilityService: %s",
|
|
480
489
|
_safe_exception_str(error),
|
|
481
490
|
)
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
self._provider = provider
|
|
494
|
+
self._meter = meter
|
|
495
|
+
self.event_count_counter = event_count_counter
|
|
496
|
+
self.event_bytes_counter = event_bytes_counter
|
|
497
|
+
self._flush_controller = controller
|
|
498
|
+
self._logger.info(
|
|
499
|
+
"ObservabilityService initialised: ta_name=%s ta_version=%s "
|
|
500
|
+
"modinput_type=%s",
|
|
501
|
+
ta_name,
|
|
502
|
+
ta_version,
|
|
503
|
+
modinput_type,
|
|
504
|
+
)
|
|
482
505
|
|
|
483
506
|
def _read_ta_info(self) -> tuple[Optional[str], Optional[str]]:
|
|
484
507
|
"""Read the add-on name and version from ``app.conf``.
|
|
@@ -657,6 +680,7 @@ class ObservabilityService:
|
|
|
657
680
|
exporter = OTLPMetricExporter(
|
|
658
681
|
endpoint=endpoint,
|
|
659
682
|
credentials=credentials,
|
|
683
|
+
timeout=2.0,
|
|
660
684
|
preferred_temporality={
|
|
661
685
|
Counter: AggregationTemporality.DELTA,
|
|
662
686
|
Histogram: AggregationTemporality.DELTA,
|
|
@@ -711,26 +735,34 @@ class ObservabilityService:
|
|
|
711
735
|
return callback(self._meter)
|
|
712
736
|
|
|
713
737
|
def flush(self, timeout_millis: float = 30_000) -> None:
|
|
714
|
-
"""
|
|
738
|
+
"""Schedule a background flush of all metric readers.
|
|
715
739
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
740
|
+
Returns promptly without waiting for an export attempt to finish.
|
|
741
|
+
Repeated calls while an export is active are coalesced. At normal
|
|
742
|
+
process exit, solnlib gives its metric workers a single five-second
|
|
743
|
+
grace period for final export; the last metrics can be lost if it
|
|
744
|
+
expires.
|
|
719
745
|
|
|
720
746
|
Prefer using :class:`StanzaObservabilityRecorder` as a context manager
|
|
721
747
|
rather than calling this method directly — it calls
|
|
722
748
|
:meth:`StanzaObservabilityRecorder.flush` on exit automatically.
|
|
723
749
|
|
|
724
750
|
Args:
|
|
725
|
-
timeout_millis:
|
|
726
|
-
|
|
751
|
+
timeout_millis: Budget forwarded to the background
|
|
752
|
+
``MeterProvider.force_flush()`` call, in milliseconds.
|
|
753
|
+
The budget starts when that call begins; waiting for the
|
|
754
|
+
worker is not included. Readers and exporters may exceed
|
|
755
|
+
this budget. It is not a hard limit on export duration.
|
|
756
|
+
Defaults to 30 seconds.
|
|
727
757
|
"""
|
|
728
|
-
if self._provider is None:
|
|
758
|
+
if self._provider is None or self._flush_controller is None:
|
|
729
759
|
return
|
|
730
760
|
try:
|
|
731
|
-
self.
|
|
761
|
+
self._flush_controller.request_flush(timeout_millis)
|
|
732
762
|
except Exception as error:
|
|
733
|
-
self._logger.info(
|
|
763
|
+
self._logger.info(
|
|
764
|
+
"Failed to schedule metrics flush: %s", _safe_exception_str(error)
|
|
765
|
+
)
|
|
734
766
|
|
|
735
767
|
|
|
736
768
|
_INT64_MIN = -(2**63)
|
|
@@ -806,7 +838,8 @@ class StanzaObservabilityRecorder:
|
|
|
806
838
|
**Best practices:**
|
|
807
839
|
|
|
808
840
|
- Use as a context manager (``with`` statement) so that :meth:`flush` is
|
|
809
|
-
always
|
|
841
|
+
always scheduled when the stanza collection loop exits, even on
|
|
842
|
+
exceptions. This does not wait for export completion.
|
|
810
843
|
- Pass the same *modinput_type* string for all stanzas of the same input
|
|
811
844
|
type. The string should be lowercase, hyphenated, and stable across
|
|
812
845
|
restarts (e.g. ``"event-hub"``).
|
|
@@ -1013,15 +1046,15 @@ class StanzaObservabilityRecorder:
|
|
|
1013
1046
|
self._service.event_bytes_counter.add(byte_count, attributes=attrs)
|
|
1014
1047
|
|
|
1015
1048
|
def flush(self) -> None:
|
|
1016
|
-
"""
|
|
1049
|
+
"""Schedule a background flush of all metric readers.
|
|
1017
1050
|
|
|
1018
|
-
Delegates to :meth:`ObservabilityService.flush
|
|
1019
|
-
``MeterProvider.force_flush()``
|
|
1020
|
-
``__exit__`` when
|
|
1021
|
-
|
|
1051
|
+
Delegates to :meth:`ObservabilityService.flush`, which calls
|
|
1052
|
+
``MeterProvider.force_flush()`` on a daemon worker. Called
|
|
1053
|
+
automatically by ``__exit__`` when used as a context manager. A
|
|
1054
|
+
returned call means the attempt was scheduled, not completed.
|
|
1022
1055
|
|
|
1023
1056
|
Call it explicitly only when you are not using the context manager and
|
|
1024
|
-
|
|
1057
|
+
want to request an export before the process exits::
|
|
1025
1058
|
|
|
1026
1059
|
obs = StanzaObservabilityRecorder("my-input", logger, stanza_name)
|
|
1027
1060
|
try:
|
|
@@ -1036,10 +1069,10 @@ class StanzaObservabilityRecorder:
|
|
|
1036
1069
|
return self
|
|
1037
1070
|
|
|
1038
1071
|
def __exit__(self, *_) -> bool:
|
|
1039
|
-
"""
|
|
1072
|
+
"""Schedule a flush and allow exceptions to propagate.
|
|
1040
1073
|
|
|
1041
1074
|
Returns ``False`` so any exception raised inside the ``with`` block
|
|
1042
|
-
is re-raised after
|
|
1075
|
+
is re-raised after the flush has been scheduled.
|
|
1043
1076
|
"""
|
|
1044
1077
|
self.flush()
|
|
1045
1078
|
return False
|
|
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
|
|
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
|