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.
Files changed (39) hide show
  1. {solnlib-8.1.1 → solnlib-8.2.0b1}/PKG-INFO +1 -1
  2. {solnlib-8.1.1 → solnlib-8.2.0b1}/pyproject.toml +1 -1
  3. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/__init__.py +1 -1
  4. solnlib-8.2.0b1/solnlib/_observability_flush.py +161 -0
  5. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/observability.py +64 -31
  6. {solnlib-8.1.1 → solnlib-8.2.0b1}/LICENSE +0 -0
  7. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/_settings.py +0 -0
  8. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/_utils.py +0 -0
  9. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/acl.py +0 -0
  10. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/alerts_rest_client.py +0 -0
  11. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/bulletin_rest_client.py +0 -0
  12. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/concurrent/concurrent_executor.py +0 -0
  13. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/concurrent/process_pool.py +0 -0
  14. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/concurrent/thread_pool.py +0 -0
  15. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/conf_manager.py +0 -0
  16. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/credentials.py +0 -0
  17. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/file_monitor.py +0 -0
  18. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/hec_config.py +0 -0
  19. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/log.py +0 -0
  20. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/__init__.py +0 -0
  21. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/checkpointer.py +0 -0
  22. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/event.py +0 -0
  23. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/event_writer.py +0 -0
  24. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/modinput.py +0 -0
  25. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/modular_input/modular_input.py +0 -0
  26. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/net_utils.py +0 -0
  27. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/orphan_process_monitor.py +0 -0
  28. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/pattern.py +0 -0
  29. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/rest.py +0 -0
  30. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/schedule/job.py +0 -0
  31. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/schedule/scheduler.py +0 -0
  32. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/server_info.py +0 -0
  33. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/soln_exceptions.py +0 -0
  34. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/splunk_rest_client.py +0 -0
  35. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/splunkenv.py +0 -0
  36. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/time_parser.py +0 -0
  37. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/timer_queue.py +0 -0
  38. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/user_access.py +0 -0
  39. {solnlib-8.1.1 → solnlib-8.2.0b1}/solnlib/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solnlib
3
- Version: 8.1.1
3
+ Version: 8.2.0b1
4
4
  Summary: The Splunk Software Development Kit for Splunk Solutions
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -16,7 +16,7 @@
16
16
 
17
17
  [tool.poetry]
18
18
  name = "solnlib"
19
- version = "8.1.1"
19
+ version = "8.2.0-beta.1"
20
20
  description = "The Splunk Software Development Kit for Splunk Solutions"
21
21
  authors = ["Splunk <addonfactory@splunk.com>"]
22
22
  license = "Apache-2.0"
@@ -55,4 +55,4 @@ __all__ = [
55
55
  "utils",
56
56
  ]
57
57
 
58
- __version__ = "8.1.1"
58
+ __version__ = "8.2.0-beta.1"
@@ -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 for automatic flush
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
- self._provider = MeterProvider(
455
- resource=resource, metric_readers=metric_readers
457
+ provider = MeterProvider(
458
+ resource=resource,
459
+ metric_readers=metric_readers,
460
+ shutdown_on_exit=False,
456
461
  )
457
- self._meter = self._provider.get_meter(ta_name, ta_version)
462
+ meter = provider.get_meter(ta_name, ta_version)
458
463
 
459
- self.event_count_counter = self._meter.create_counter(
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
- self.event_bytes_counter = self._meter.create_counter(
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
- self._logger.info(
471
- "ObservabilityService initialised: ta_name=%s ta_version=%s "
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
- """Force-flush all metric readers.
738
+ """Schedule a background flush of all metric readers.
715
739
 
716
- Blocks until all buffered data points have been handed off to their
717
- exporters or *timeout_millis* elapses. Call this before the modular
718
- input process exits to avoid dropping the last batch of metrics.
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: Maximum time to wait for exporters to drain, in
726
- milliseconds. Defaults to 30 seconds.
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._provider.force_flush(timeout_millis=int(timeout_millis))
761
+ self._flush_controller.request_flush(timeout_millis)
732
762
  except Exception as error:
733
- self._logger.info("Failed to flush metrics: %s", _safe_exception_str(error))
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 called when the stanza collection loop exits, even on exceptions.
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
- """Force-flush all metric readers.
1049
+ """Schedule a background flush of all metric readers.
1017
1050
 
1018
- Delegates to :meth:`ObservabilityService.flush` (which calls
1019
- ``MeterProvider.force_flush()`` internally). Called automatically by
1020
- ``__exit__`` when the recorder is used as a context manager, so you
1021
- rarely need to call this directly.
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
- need to guarantee delivery before the process exits::
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
- """Flush all metric readers and allow exceptions to propagate.
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 flushing.
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