solnlib 8.0.0__tar.gz → 8.1.0__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 (38) hide show
  1. {solnlib-8.0.0 → solnlib-8.1.0}/PKG-INFO +1 -1
  2. {solnlib-8.0.0 → solnlib-8.1.0}/pyproject.toml +1 -1
  3. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/__init__.py +1 -1
  4. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/observability.py +290 -28
  5. {solnlib-8.0.0 → solnlib-8.1.0}/LICENSE +0 -0
  6. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/_settings.py +0 -0
  7. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/_utils.py +0 -0
  8. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/acl.py +0 -0
  9. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/alerts_rest_client.py +0 -0
  10. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/bulletin_rest_client.py +0 -0
  11. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/concurrent/concurrent_executor.py +0 -0
  12. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/concurrent/process_pool.py +0 -0
  13. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/concurrent/thread_pool.py +0 -0
  14. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/conf_manager.py +0 -0
  15. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/credentials.py +0 -0
  16. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/file_monitor.py +0 -0
  17. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/hec_config.py +0 -0
  18. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/log.py +0 -0
  19. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/modular_input/__init__.py +0 -0
  20. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/modular_input/checkpointer.py +0 -0
  21. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/modular_input/event.py +0 -0
  22. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/modular_input/event_writer.py +0 -0
  23. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/modular_input/modinput.py +0 -0
  24. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/modular_input/modular_input.py +0 -0
  25. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/net_utils.py +0 -0
  26. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/orphan_process_monitor.py +0 -0
  27. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/pattern.py +0 -0
  28. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/rest.py +0 -0
  29. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/schedule/job.py +0 -0
  30. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/schedule/scheduler.py +0 -0
  31. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/server_info.py +0 -0
  32. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/soln_exceptions.py +0 -0
  33. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/splunk_rest_client.py +0 -0
  34. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/splunkenv.py +0 -0
  35. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/time_parser.py +0 -0
  36. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/timer_queue.py +0 -0
  37. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/user_access.py +0 -0
  38. {solnlib-8.0.0 → solnlib-8.1.0}/solnlib/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solnlib
3
- Version: 8.0.0
3
+ Version: 8.1.0
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.0.0"
19
+ version = "8.1.0"
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.0.0"
58
+ __version__ = "8.1.0"
@@ -15,7 +15,7 @@
15
15
  #
16
16
  """OpenTelemetry observability utilities for Splunk add-ons.
17
17
 
18
- This module provides two public components:
18
+ This module provides three public components:
19
19
 
20
20
  - :class:`LoggerMetricExporter` — an OpenTelemetry ``MetricExporter`` that
21
21
  writes every exported data point to a standard Python logger. It is useful
@@ -28,39 +28,30 @@ This module provides two public components:
28
28
  Splunk Spotlight OTLP collector and falls back silently when it is not
29
29
  reachable, so callers never have to handle observability failures themselves.
30
30
 
31
+ - :class:`StanzaObservabilityRecorder` — a stanza-scoped recorder that wraps
32
+ ``ObservabilityService`` with a per-process singleton cache. Bind it to a
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.
36
+
31
37
  Typical usage::
32
38
 
33
39
  import logging
34
- from solnlib.observability import LoggerMetricExporter, ObservabilityService, ATTR_MODINPUT_NAME
40
+ from solnlib.observability import StanzaObservabilityRecorder
35
41
 
36
42
  logger = logging.getLogger(__name__)
37
43
 
38
- obs = ObservabilityService(
39
- modinput_type="my-input",
40
- logger=logger,
41
- ta_name="my_ta",
42
- ta_version="1.0.0",
43
- extra_exporters=[LoggerMetricExporter(logger)],
44
- )
45
-
46
- # In your event collection loop:
47
- if obs.event_count_counter:
48
- obs.event_count_counter.add(
49
- len(events), {ATTR_MODINPUT_NAME: stanza_name}
50
- )
51
- if obs.event_bytes_counter:
52
- obs.event_bytes_counter.add(
53
- total_bytes, {ATTR_MODINPUT_NAME: stanza_name}
54
- )
44
+ with StanzaObservabilityRecorder("my-input", logger, stanza_name) as obs:
45
+ obs.record(len(events), total_bytes)
55
46
  """
56
47
 
57
48
  import json
58
49
  import logging
59
50
  import os
60
51
  import ssl
52
+ import threading
61
53
  import urllib.request
62
- from typing import Callable, Optional, Union
63
- import grpc
54
+ from typing import Callable, ClassVar, Optional, Union
64
55
  from .splunkenv import get_conf_stanzas
65
56
  from opentelemetry.metrics import Instrument, Meter
66
57
  from opentelemetry.sdk.metrics import MeterProvider, Counter, Histogram
@@ -72,7 +63,6 @@ from opentelemetry.sdk.metrics.export import (
72
63
  AggregationTemporality,
73
64
  )
74
65
  from opentelemetry.sdk.resources import Resource
75
- from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
76
66
 
77
67
  _Logger = Union[logging.Logger, logging.LoggerAdapter]
78
68
 
@@ -283,6 +273,7 @@ class ObservabilityService:
283
273
  self.event_count_counter: Optional[Counter] = None
284
274
  self.event_bytes_counter: Optional[Counter] = None
285
275
  self._meter: Optional[Meter] = None
276
+ self._provider: Optional[MeterProvider] = None
286
277
 
287
278
  try:
288
279
  if ta_name is None or ta_version is None:
@@ -313,8 +304,10 @@ class ObservabilityService:
313
304
  for exporter in extra_exporters or []:
314
305
  metric_readers.append(PeriodicExportingMetricReader(exporter))
315
306
 
316
- provider = MeterProvider(resource=resource, metric_readers=metric_readers)
317
- self._meter = provider.get_meter(ta_name, ta_version)
307
+ self._provider = MeterProvider(
308
+ resource=resource, metric_readers=metric_readers
309
+ )
310
+ self._meter = self._provider.get_meter(ta_name, ta_version)
318
311
 
319
312
  self.event_count_counter = self._meter.create_counter(
320
313
  name="splunk.addon.events",
@@ -435,20 +428,34 @@ class ObservabilityService:
435
428
  return self._discover_otlp_port_via_ipc_broker()
436
429
 
437
430
  def _create_otlp_exporter(self) -> Optional[MetricExporter]:
438
- """Create a TLS-secured OTLP gRPC exporter targeting the Spotlight
439
- collector.
431
+ """Create a TLS-secured OTLP gRPC exporter targeting the Spotlight collector.
432
+
433
+ ``grpc`` and ``OTLPMetricExporter`` are imported lazily inside this
434
+ method so that ``import solnlib.observability`` succeeds in environments
435
+ where ``grpcio`` is not installed. The import only fails when OTLP
436
+ export is actually attempted.
440
437
 
441
438
  The collector's server certificate is read from
442
439
  ``$SPLUNK_HOME/var/packages/data/spotlight-collector/server.crt``
443
440
  (defaults to ``/opt/splunk`` when ``SPLUNK_HOME`` is not set).
444
441
 
442
+ Both ``Counter`` and ``Histogram`` instruments are configured with
443
+ ``AggregationTemporality.DELTA`` so that each export interval reports
444
+ only the change since the previous interval.
445
+
445
446
  Returns the configured exporter, or ``None`` when:
446
447
 
447
448
  - The OTLP port cannot be resolved (see :meth:`_resolve_otlp_port`).
448
449
  - The certificate file does not exist.
449
- - Any other exception occurs during exporter construction.
450
+ - Any other exception occurs during exporter construction (including a
451
+ missing ``grpcio`` package).
450
452
  """
451
453
  try:
454
+ import grpc
455
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
456
+ OTLPMetricExporter,
457
+ )
458
+
452
459
  splunk_home = os.environ.get("SPLUNK_HOME", "/opt/splunk")
453
460
  otel_port = self._resolve_otlp_port()
454
461
 
@@ -484,7 +491,14 @@ class ObservabilityService:
484
491
  server_cert = f.read()
485
492
 
486
493
  credentials = grpc.ssl_channel_credentials(root_certificates=server_cert)
487
- exporter = OTLPMetricExporter(endpoint=endpoint, credentials=credentials)
494
+ exporter = OTLPMetricExporter(
495
+ endpoint=endpoint,
496
+ credentials=credentials,
497
+ preferred_temporality={
498
+ Counter: AggregationTemporality.DELTA,
499
+ Histogram: AggregationTemporality.DELTA,
500
+ },
501
+ )
488
502
  self._logger.info("OTLP gRPC exporter configured with TLS for %s", endpoint)
489
503
  return exporter
490
504
 
@@ -531,3 +545,251 @@ class ObservabilityService:
531
545
  if self._meter is None:
532
546
  return None
533
547
  return callback(self._meter)
548
+
549
+ def flush(self, timeout_millis: float = 30_000) -> None:
550
+ """Force-flush all metric readers.
551
+
552
+ Blocks until all buffered data points have been handed off to their
553
+ exporters or *timeout_millis* elapses. Call this before the modular
554
+ input process exits to avoid dropping the last batch of metrics.
555
+
556
+ Prefer using :class:`StanzaObservabilityRecorder` as a context manager
557
+ rather than calling this method directly — it calls
558
+ :meth:`StanzaObservabilityRecorder.flush` on exit automatically.
559
+
560
+ Args:
561
+ timeout_millis: Maximum time to wait for exporters to drain, in
562
+ milliseconds. Defaults to 30 seconds.
563
+ """
564
+ if self._provider is None:
565
+ return
566
+ try:
567
+ self._provider.force_flush(timeout_millis=int(timeout_millis))
568
+ except Exception as e:
569
+ self._logger.warning("Failed to flush metrics: %s", e)
570
+
571
+
572
+ class StanzaObservabilityRecorder:
573
+ """Stanza-scoped observability recorder backed by a shared ``ObservabilityService``.
574
+
575
+ One ``ObservabilityService`` is created per *modinput_type* per process and
576
+ cached in :attr:`_instances` for the lifetime of the process. Every
577
+ ``StanzaObservabilityRecorder`` for the same *modinput_type* shares that
578
+ service regardless of how many stanzas are active, so the OTLP connection
579
+ and ``MeterProvider`` are only initialised once.
580
+
581
+ Each recorder instance is bound to a single *stanza_name*, which is
582
+ automatically attached as the ``"splunk.modinput.name"`` attribute on every
583
+ recorded data point.
584
+
585
+ **Best practices:**
586
+
587
+ - Use as a context manager (``with`` statement) so that :meth:`flush` is
588
+ always called when the stanza collection loop exits, even on exceptions.
589
+ - Pass the same *modinput_type* string for all stanzas of the same input
590
+ type. The string should be lowercase, hyphenated, and stable across
591
+ restarts (e.g. ``"event-hub"``).
592
+ - Do not store the recorder beyond the lifetime of a single stanza
593
+ collection cycle — create a new instance for each run.
594
+ - Register custom instruments via :meth:`register_instrument` on the
595
+ recorder instance rather than accessing the underlying service directly.
596
+ - :class:`StanzaObservabilityRecorder` is **thread-safe** at the singleton
597
+ level (``_lock`` protects ``_instances``), but individual recorder
598
+ instances are not meant to be shared across threads.
599
+
600
+ **Typical usage**::
601
+
602
+ import logging
603
+ from solnlib.observability import StanzaObservabilityRecorder
604
+
605
+ logger = logging.getLogger(__name__)
606
+
607
+ def collect(stanza_name: str) -> None:
608
+ with StanzaObservabilityRecorder("my-input", logger, stanza_name) as obs:
609
+ events = fetch_events()
610
+ obs.record(len(events), sum(len(e) for e in events))
611
+
612
+ **Custom instrument** (e.g. latency histogram)::
613
+
614
+ from solnlib.observability import StanzaObservabilityRecorder, ATTR_MODINPUT_NAME
615
+
616
+ with StanzaObservabilityRecorder("my-input", logger, stanza_name) as obs:
617
+ latency_histogram = obs.register_instrument(
618
+ lambda meter: meter.create_histogram(
619
+ name="my_ta.request.latency",
620
+ description="Latency of outbound API requests",
621
+ unit="s",
622
+ )
623
+ )
624
+ # ... collect events ...
625
+ if latency_histogram:
626
+ latency_histogram.record(elapsed, {ATTR_MODINPUT_NAME: stanza_name})
627
+ """
628
+
629
+ _instances: ClassVar[dict[str, ObservabilityService]] = {}
630
+ _lock: ClassVar[threading.Lock] = threading.Lock()
631
+
632
+ def __init__(
633
+ self,
634
+ modinput_type: str,
635
+ logger: _Logger,
636
+ stanza_name: str,
637
+ ) -> None:
638
+ """Initialise a stanza-scoped recorder.
639
+
640
+ Gets or creates the shared :class:`ObservabilityService` for
641
+ *modinput_type* (singleton per process), then emits a zero baseline
642
+ on both built-in counters so that the metric series is visible in
643
+ dashboards from the very first collection cycle even when no events
644
+ were ingested.
645
+
646
+ Args:
647
+ modinput_type: Low-cardinality identifier for the input type,
648
+ e.g. ``"event-hub"``. All recorders for the same input type
649
+ share a single ``ObservabilityService``.
650
+ logger: Python logger used both for ``ObservabilityService``
651
+ diagnostics and for the :class:`LoggerMetricExporter` that
652
+ is automatically added as an extra exporter.
653
+ stanza_name: The name of the input stanza being collected (e.g.
654
+ ``"my_stanza"``). Attached as ``"splunk.modinput.name"``
655
+ on every recorded data point.
656
+ """
657
+ self._stanza_name = stanza_name
658
+ self._service = self._get_or_create_service(modinput_type, logger)
659
+ self._emit_zero_baseline()
660
+
661
+ @classmethod
662
+ def _get_or_create_service(
663
+ cls, modinput_type: str, logger: _Logger
664
+ ) -> ObservabilityService:
665
+ """Return the cached service for *modinput_type*, creating it if needed.
666
+
667
+ Thread-safe: uses ``_lock`` to ensure exactly one
668
+ ``ObservabilityService`` is created per *modinput_type* even when
669
+ multiple stanzas are initialised concurrently at process start.
670
+ """
671
+ with cls._lock:
672
+ if modinput_type not in cls._instances:
673
+ cls._instances[modinput_type] = ObservabilityService(
674
+ modinput_type=modinput_type,
675
+ logger=logger,
676
+ extra_exporters=[LoggerMetricExporter(logger)],
677
+ )
678
+ return cls._instances[modinput_type]
679
+
680
+ def register_instrument(
681
+ self, callback: Callable[[Meter], Instrument]
682
+ ) -> Optional[Instrument]:
683
+ """Create a custom instrument on the shared meter.
684
+
685
+ Delegates to :meth:`ObservabilityService.register_instrument`. The
686
+ instrument is registered on the process-wide ``MeterProvider``, so it
687
+ is shared across all recorders for the same *modinput_type*. Calling
688
+ this method on any recorder instance for a given *modinput_type* is
689
+ equivalent — register each instrument only once.
690
+
691
+ Returns ``None`` when :class:`ObservabilityService` failed to
692
+ initialise (e.g. because ``ta_name`` could not be determined). Always
693
+ guard the returned value before recording::
694
+
695
+ latency_histogram = obs.register_instrument(
696
+ lambda meter: meter.create_histogram(
697
+ name="my_ta.request.latency",
698
+ description="Latency of outbound API requests",
699
+ unit="s",
700
+ )
701
+ )
702
+ if latency_histogram:
703
+ latency_histogram.record(elapsed, {ATTR_MODINPUT_NAME: self._stanza_name})
704
+
705
+ Args:
706
+ callback: Callable that receives the ``Meter`` and returns a new
707
+ instrument (Counter, Histogram, Gauge, etc.).
708
+
709
+ Returns:
710
+ The instrument created by *callback*, or ``None``.
711
+ """
712
+ return self._service.register_instrument(callback)
713
+
714
+ def record(
715
+ self,
716
+ event_count: int,
717
+ byte_count: int,
718
+ extra_attrs: Optional[dict] = None,
719
+ ) -> None:
720
+ """Add *event_count* and *byte_count* to the built-in counters.
721
+
722
+ The ``"splunk.modinput.name"`` attribute is always set to the
723
+ *stanza_name* supplied at construction time and cannot be overridden by
724
+ *extra_attrs*. This preserves the stanza-scoped guarantee — every data
725
+ point is unambiguously attributed to the stanza that recorded it.
726
+
727
+ Silently no-ops if either counter is ``None`` (i.e.
728
+ :class:`ObservabilityService` failed to initialise).
729
+
730
+ Args:
731
+ event_count: Number of events ingested in this batch. Pass ``0``
732
+ for an explicit "no events" observation.
733
+ byte_count: Total size of the ingested events in bytes.
734
+ extra_attrs: Optional dict of additional OpenTelemetry attributes
735
+ to attach to both data points. Keys must be strings; values
736
+ must be strings, booleans, or numbers. Avoid high-cardinality
737
+ keys such as user IDs or GUIDs.
738
+
739
+ Example::
740
+
741
+ obs.record(
742
+ event_count=len(events),
743
+ byte_count=sum(len(e) for e in events),
744
+ extra_attrs={"my_ta.partition": partition_id},
745
+ )
746
+ """
747
+ attrs = dict(extra_attrs) if extra_attrs else {}
748
+ attrs[ATTR_MODINPUT_NAME] = self._stanza_name
749
+ if self._service.event_count_counter:
750
+ self._service.event_count_counter.add(event_count, attributes=attrs)
751
+ if self._service.event_bytes_counter:
752
+ self._service.event_bytes_counter.add(byte_count, attributes=attrs)
753
+
754
+ def flush(self) -> None:
755
+ """Force-flush all metric readers.
756
+
757
+ Delegates to :meth:`ObservabilityService.flush` (which calls
758
+ ``MeterProvider.force_flush()`` internally). Called automatically by
759
+ ``__exit__`` when the recorder is used as a context manager, so you
760
+ rarely need to call this directly.
761
+
762
+ Call it explicitly only when you are not using the context manager and
763
+ need to guarantee delivery before the process exits::
764
+
765
+ obs = StanzaObservabilityRecorder("my-input", logger, stanza_name)
766
+ try:
767
+ obs.record(len(events), total_bytes)
768
+ finally:
769
+ obs.flush()
770
+ """
771
+ self._service.flush()
772
+
773
+ def __enter__(self) -> "StanzaObservabilityRecorder":
774
+ """Return *self* to support the ``with`` statement."""
775
+ return self
776
+
777
+ def __exit__(self, *_) -> bool:
778
+ """Flush all metric readers and allow exceptions to propagate.
779
+
780
+ Returns ``False`` so any exception raised inside the ``with`` block
781
+ is re-raised after flushing.
782
+ """
783
+ self.flush()
784
+ return False
785
+
786
+ def _emit_zero_baseline(self) -> None:
787
+ """Emit ``add(0)`` on both built-in counters.
788
+
789
+ Called once from ``__init__``. Ensures that the metric series for this
790
+ stanza appears in dashboards and alerting rules from the very first
791
+ collection cycle, even when no events were ingested. Without this
792
+ baseline, a stanza that has never produced data is indistinguishable
793
+ from a stanza that has never been seen at all.
794
+ """
795
+ self.record(0, 0)
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