solnlib 8.1.0__tar.gz → 8.1.0b2__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.1.0 → solnlib-8.1.0b2}/PKG-INFO +1 -1
  2. {solnlib-8.1.0 → solnlib-8.1.0b2}/pyproject.toml +1 -1
  3. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/__init__.py +1 -1
  4. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/observability.py +287 -26
  5. {solnlib-8.1.0 → solnlib-8.1.0b2}/LICENSE +0 -0
  6. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/_settings.py +0 -0
  7. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/_utils.py +0 -0
  8. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/acl.py +0 -0
  9. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/alerts_rest_client.py +0 -0
  10. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/bulletin_rest_client.py +0 -0
  11. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/concurrent/concurrent_executor.py +0 -0
  12. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/concurrent/process_pool.py +0 -0
  13. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/concurrent/thread_pool.py +0 -0
  14. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/conf_manager.py +0 -0
  15. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/credentials.py +0 -0
  16. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/file_monitor.py +0 -0
  17. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/hec_config.py +0 -0
  18. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/log.py +0 -0
  19. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/modular_input/__init__.py +0 -0
  20. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/modular_input/checkpointer.py +0 -0
  21. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/modular_input/event.py +0 -0
  22. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/modular_input/event_writer.py +0 -0
  23. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/modular_input/modinput.py +0 -0
  24. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/modular_input/modular_input.py +0 -0
  25. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/net_utils.py +0 -0
  26. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/orphan_process_monitor.py +0 -0
  27. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/pattern.py +0 -0
  28. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/rest.py +0 -0
  29. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/schedule/job.py +0 -0
  30. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/schedule/scheduler.py +0 -0
  31. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/server_info.py +0 -0
  32. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/soln_exceptions.py +0 -0
  33. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/splunk_rest_client.py +0 -0
  34. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/splunkenv.py +0 -0
  35. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/time_parser.py +0 -0
  36. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/timer_queue.py +0 -0
  37. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/user_access.py +0 -0
  38. {solnlib-8.1.0 → solnlib-8.1.0b2}/solnlib/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solnlib
3
- Version: 8.1.0
3
+ Version: 8.1.0b2
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.0"
19
+ version = "8.1.0-beta.2"
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.0"
58
+ __version__ = "8.1.0-beta.2"
@@ -73,6 +73,73 @@ _SERVICE_NAMESPACE = "splunk.addon"
73
73
  ATTR_MODINPUT_NAME = "splunk.modinput.name"
74
74
 
75
75
 
76
+ def _sanitize_for_log(text) -> str:
77
+ """Return one bounded, UTF-8-safe physical log-line fragment."""
78
+ try:
79
+ # Calling the base implementation directly neutralizes overridden
80
+ # methods on a str subclass and returns a genuine plain str.
81
+ text = str.__str__(text) if isinstance(text, str) else str(text)
82
+ text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
83
+ text = text.encode("utf-8", errors="replace").decode("utf-8")
84
+ if len(text) > 500:
85
+ text = text[:500] + "...(truncated)"
86
+ return text
87
+ except BaseException:
88
+ return "<unrepresentable>"
89
+
90
+
91
+ def _safe_exception_repr(error: BaseException) -> str:
92
+ try:
93
+ return _sanitize_for_log(repr(error))
94
+ except BaseException:
95
+ pass
96
+ try:
97
+ return _sanitize_for_log(f"{type(error).__name__} (repr unavailable)")
98
+ except BaseException:
99
+ return "<exception repr unavailable>"
100
+
101
+
102
+ def _safe_exception_str(error: BaseException) -> str:
103
+ try:
104
+ return _sanitize_for_log(str(error))
105
+ except BaseException:
106
+ pass
107
+ try:
108
+ return _sanitize_for_log(f"{type(error).__name__} (details unavailable)")
109
+ except BaseException:
110
+ return "<exception details unavailable>"
111
+
112
+
113
+ class _DowngradeToInfoFilter(logging.Filter):
114
+ def filter(self, record: logging.LogRecord) -> bool:
115
+ if record.levelno > logging.INFO:
116
+ record.levelno = logging.INFO
117
+ record.levelname = "INFO"
118
+ return True
119
+
120
+
121
+ _downgrade_to_info_filter = _DowngradeToInfoFilter()
122
+
123
+ # Intentional process-wide policy: observability is auxiliary functionality, so
124
+ # diagnostics emitted by the OpenTelemetry SDK and exporters are capped at INFO
125
+ # across all signal types (metrics, logs, and traces). This preserves diagnostic
126
+ # messages without surfacing observability failures as add-on WARNING/ERROR events.
127
+ _OTLP_LOGGERS = (
128
+ "opentelemetry.exporter.otlp.proto.grpc.exporter",
129
+ "opentelemetry.util.re",
130
+ "opentelemetry.exporter.otlp.proto.common._internal.metrics_encoder",
131
+ "opentelemetry.exporter.otlp.proto.common._internal",
132
+ )
133
+
134
+ _METRICS_SDK_LOGGERS = (
135
+ "opentelemetry.sdk.metrics._internal.export",
136
+ "opentelemetry.sdk.metrics._internal",
137
+ "opentelemetry.sdk.metrics._internal.instrument",
138
+ "opentelemetry.metrics._internal",
139
+ "opentelemetry.attributes",
140
+ )
141
+
142
+
76
143
  class LoggerMetricExporter(MetricExporter):
77
144
  """An OpenTelemetry ``MetricExporter`` that logs every data point.
78
145
 
@@ -166,8 +233,10 @@ class LoggerMetricExporter(MetricExporter):
166
233
  metric_count,
167
234
  )
168
235
  return MetricExportResult.SUCCESS
169
- except Exception as e:
170
- self._logger.error("Failed to export metrics: %s", e, exc_info=True)
236
+ except Exception as error:
237
+ self._logger.info(
238
+ "Failed to export metrics: %s", _safe_exception_str(error)
239
+ )
171
240
  return MetricExportResult.FAILURE
172
241
 
173
242
  def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
@@ -179,12 +248,79 @@ class LoggerMetricExporter(MetricExporter):
179
248
  return True
180
249
 
181
250
 
251
+ class _CircuitBreakerExporter(MetricExporter):
252
+ """Wraps the internally constructed OTLP exporter and stops calling it
253
+ after 3 consecutive failed exports in this process."""
254
+
255
+ _MAX_CONSECUTIVE_FAILURES = 3
256
+
257
+ def __init__(self, inner: MetricExporter, logger: _Logger) -> None:
258
+ super().__init__(
259
+ preferred_temporality=inner._preferred_temporality,
260
+ preferred_aggregation=inner._preferred_aggregation,
261
+ )
262
+ self._inner = inner
263
+ self._logger = logger
264
+ self._consecutive_failures = 0
265
+ self._tripped = False
266
+ self._shutdown_called = False
267
+ self._lock = threading.Lock()
268
+
269
+ def export(
270
+ self,
271
+ metrics_data: MetricsData,
272
+ timeout_millis: float = 10_000,
273
+ **kwargs,
274
+ ) -> MetricExportResult:
275
+ with self._lock:
276
+ if self._tripped:
277
+ return MetricExportResult.SUCCESS
278
+
279
+ # Never hold _lock across this blocking call: shutdown() must be able
280
+ # to reach the inner exporter and interrupt an in-flight retry even
281
+ # while an export is still outstanding on another thread.
282
+ try:
283
+ result = self._inner.export(
284
+ metrics_data, timeout_millis=timeout_millis, **kwargs
285
+ )
286
+ except Exception as error:
287
+ self._logger.info(
288
+ "OTLP export raised an exception: %s",
289
+ _safe_exception_repr(error),
290
+ )
291
+ result = MetricExportResult.FAILURE
292
+
293
+ with self._lock:
294
+ if result == MetricExportResult.SUCCESS:
295
+ self._consecutive_failures = 0
296
+ return result
297
+
298
+ self._consecutive_failures += 1
299
+ if self._consecutive_failures >= self._MAX_CONSECUTIVE_FAILURES:
300
+ self._tripped = True
301
+ self._logger.info("OTLP export disabled after 3 consecutive failures")
302
+ return result
303
+
304
+ def force_flush(self, timeout_millis: float = 10_000) -> bool:
305
+ with self._lock:
306
+ if self._tripped:
307
+ return True
308
+ return self._inner.force_flush(timeout_millis=timeout_millis)
309
+
310
+ def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
311
+ with self._lock:
312
+ if self._shutdown_called:
313
+ return
314
+ self._shutdown_called = True
315
+ self._inner.shutdown(timeout_millis=timeout_millis, **kwargs)
316
+
317
+
182
318
  class ObservabilityService:
183
319
  """OpenTelemetry observability service for a Splunk modular input.
184
320
 
185
321
  Sets up a ``MeterProvider`` with two built-in event counters and,
186
322
  when the Spotlight collector is reachable, an OTLP gRPC exporter.
187
- Initialisation failures are caught and logged as warnings so that a
323
+ Initialisation failures are caught and logged at INFO so that a
188
324
  missing or misconfigured observability stack never breaks the add-on.
189
325
 
190
326
  **Resource attributes** (fixed for the lifetime of the process):
@@ -275,7 +411,18 @@ class ObservabilityService:
275
411
  self._meter: Optional[Meter] = None
276
412
  self._provider: Optional[MeterProvider] = None
277
413
 
414
+ for logger_name in _METRICS_SDK_LOGGERS:
415
+ logging.getLogger(logger_name).addFilter(_downgrade_to_info_filter)
416
+
278
417
  try:
418
+ if type(modinput_type) is not str or not _is_safe_identifier_str(
419
+ modinput_type
420
+ ):
421
+ raise ValueError(
422
+ "modinput_type must be a str without CR/LF, got "
423
+ f"{_sanitize_for_log(type(modinput_type).__name__)}"
424
+ )
425
+
279
426
  if ta_name is None or ta_version is None:
280
427
  _ta_name, _ta_version = self._read_ta_info()
281
428
  ta_name = ta_name or _ta_name
@@ -327,8 +474,11 @@ class ObservabilityService:
327
474
  ta_version,
328
475
  modinput_type,
329
476
  )
330
- except Exception as e:
331
- self._logger.warning("Failed to initialise ObservabilityService: %s", e)
477
+ except Exception as error:
478
+ self._logger.info(
479
+ "Failed to initialise ObservabilityService: %s",
480
+ _safe_exception_str(error),
481
+ )
332
482
 
333
483
  def _read_ta_info(self) -> tuple[Optional[str], Optional[str]]:
334
484
  """Read the add-on name and version from ``app.conf``.
@@ -345,8 +495,11 @@ class ObservabilityService:
345
495
  )
346
496
  ta_version = scoped_stanzas.get("launcher", {}).get("version") or None
347
497
  return ta_name, ta_version
348
- except Exception as e:
349
- self._logger.warning("Failed to read TA info from app.conf: %s", e)
498
+ except Exception as error:
499
+ self._logger.info(
500
+ "Failed to read TA info from app.conf: %s",
501
+ _safe_exception_str(error),
502
+ )
350
503
  return None, None
351
504
 
352
505
  def _get_ipc_broker_port(self) -> Optional[int]:
@@ -359,9 +512,10 @@ class ObservabilityService:
359
512
  try:
360
513
  stanzas = get_conf_stanzas("server")
361
514
  return int(stanzas["ipc_broker"]["port"])
362
- except Exception as e:
363
- self._logger.warning(
364
- "Failed to read IPC broker port from server.conf: %s", e
515
+ except Exception as error:
516
+ self._logger.info(
517
+ "Failed to read IPC broker port from server.conf: %s",
518
+ _safe_exception_str(error),
365
519
  )
366
520
  return None
367
521
 
@@ -376,7 +530,7 @@ class ObservabilityService:
376
530
  """
377
531
  ipc_broker_port = self._get_ipc_broker_port()
378
532
  if ipc_broker_port is None:
379
- self._logger.warning("IPC broker port not found in server.conf")
533
+ self._logger.info("IPC broker port not found in server.conf")
380
534
  return None
381
535
 
382
536
  url = (
@@ -397,15 +551,18 @@ class ObservabilityService:
397
551
  data = json.loads(resp.read().decode())
398
552
 
399
553
  if not data.get("success"):
400
- self._logger.warning(
554
+ self._logger.info(
401
555
  "IPC broker discovery returned unsuccessful response: %s", data
402
556
  )
403
557
  return None
404
558
  port = str(data["port"])
405
559
  self._logger.info("Discovered OTLP port via IPC broker: %s", port)
406
560
  return port
407
- except Exception as e:
408
- self._logger.warning("IPC broker OTLP port discovery failed: %s", e)
561
+ except Exception as error:
562
+ self._logger.info(
563
+ "IPC broker OTLP port discovery failed: %s",
564
+ _safe_exception_str(error),
565
+ )
409
566
  return None
410
567
 
411
568
  def _resolve_otlp_port(self) -> Optional[str]:
@@ -443,13 +600,16 @@ class ObservabilityService:
443
600
  ``AggregationTemporality.DELTA`` so that each export interval reports
444
601
  only the change since the previous interval.
445
602
 
446
- Returns the configured exporter, or ``None`` when:
603
+ Returns the configured exporter wrapped in ``_CircuitBreakerExporter``,
604
+ or ``None`` when:
447
605
 
448
606
  - The OTLP port cannot be resolved (see :meth:`_resolve_otlp_port`).
449
607
  - The certificate file does not exist.
450
608
  - Any other exception occurs during exporter construction (including a
451
609
  missing ``grpcio`` package).
452
610
  """
611
+ os.environ.setdefault("GRPC_VERBOSITY", "NONE")
612
+
453
613
  try:
454
614
  import grpc
455
615
  from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
@@ -466,7 +626,7 @@ class ObservabilityService:
466
626
  )
467
627
 
468
628
  if not otel_port:
469
- self._logger.warning(
629
+ self._logger.info(
470
630
  "OTLP port could not be determined from env or IPC broker, "
471
631
  "OTLP export disabled"
472
632
  )
@@ -481,12 +641,15 @@ class ObservabilityService:
481
641
  )
482
642
 
483
643
  if not os.path.exists(cert_file):
484
- self._logger.error(
644
+ self._logger.info(
485
645
  "OTel Collector certificate not found at %s, OTLP export disabled",
486
646
  cert_file,
487
647
  )
488
648
  return None
489
649
 
650
+ for logger_name in _OTLP_LOGGERS:
651
+ logging.getLogger(logger_name).addFilter(_downgrade_to_info_filter)
652
+
490
653
  with open(cert_file, "rb") as f:
491
654
  server_cert = f.read()
492
655
 
@@ -500,11 +663,12 @@ class ObservabilityService:
500
663
  },
501
664
  )
502
665
  self._logger.info("OTLP gRPC exporter configured with TLS for %s", endpoint)
503
- return exporter
666
+ return _CircuitBreakerExporter(exporter, self._logger)
504
667
 
505
- except Exception as e:
506
- self._logger.warning(
507
- "Failed to configure OTLP exporter: %s", e, exc_info=True
668
+ except Exception as error:
669
+ self._logger.info(
670
+ "Failed to configure OTLP exporter: %s",
671
+ _safe_exception_str(error),
508
672
  )
509
673
  return None
510
674
 
@@ -565,8 +729,65 @@ class ObservabilityService:
565
729
  return
566
730
  try:
567
731
  self._provider.force_flush(timeout_millis=int(timeout_millis))
568
- except Exception as e:
569
- self._logger.warning("Failed to flush metrics: %s", e)
732
+ except Exception as error:
733
+ self._logger.info("Failed to flush metrics: %s", _safe_exception_str(error))
734
+
735
+
736
+ _INT64_MIN = -(2**63)
737
+ _INT64_MAX = 2**63 - 1
738
+
739
+
740
+ def _is_encodable_str(value: str) -> bool:
741
+ # Caller guarantees type(value) is str.
742
+ try:
743
+ value.encode("utf-8")
744
+ return True
745
+ except UnicodeEncodeError:
746
+ return False
747
+
748
+
749
+ def _is_safe_identifier_str(value: str) -> bool:
750
+ return _is_encodable_str(value) and "\n" not in value and "\r" not in value
751
+
752
+
753
+ def _count_error(value) -> Optional[str]:
754
+ """Return None for a valid event/byte count, else a safe reason string."""
755
+ if type(value) is not int:
756
+ return f"expected int, got {_sanitize_for_log(type(value).__name__)}"
757
+ if value < 0:
758
+ return "count is negative"
759
+ if value > _INT64_MAX:
760
+ return f"count exceeds int64 range ({value.bit_length()} bits)"
761
+ return None
762
+
763
+
764
+ def _attr_key_error(key) -> Optional[str]:
765
+ """Return None for a valid attribute key, else a safe reason string."""
766
+ if type(key) is not str:
767
+ return f"expected str key, got {_sanitize_for_log(type(key).__name__)}"
768
+ if not key:
769
+ return "key is empty"
770
+ if not _is_safe_identifier_str(key):
771
+ return "key is not UTF-8 encodable or contains CR/LF"
772
+ return None
773
+
774
+
775
+ def _attr_value_error(value) -> Optional[str]:
776
+ """Return None for a valid attribute value, else a safe reason string."""
777
+ value_type = type(value)
778
+ if value_type is bool:
779
+ return None
780
+ if value_type is int:
781
+ if _INT64_MIN <= value <= _INT64_MAX:
782
+ return None
783
+ return f"int value out of int64 range ({value.bit_length()} bits)"
784
+ if value_type is float:
785
+ return None
786
+ if value_type is str:
787
+ if _is_encodable_str(value):
788
+ return None
789
+ return "value is not UTF-8 encodable"
790
+ return f"unsupported value type: {_sanitize_for_log(value_type.__name__)}"
570
791
 
571
792
 
572
793
  class StanzaObservabilityRecorder:
@@ -654,6 +875,17 @@ class StanzaObservabilityRecorder:
654
875
  ``"my_stanza"``). Attached as ``"splunk.modinput.name"``
655
876
  on every recorded data point.
656
877
  """
878
+ self._logger = logger
879
+ if type(modinput_type) is not str or not _is_safe_identifier_str(modinput_type):
880
+ raise TypeError(
881
+ "modinput_type must be a str without CR/LF, got "
882
+ f"{_sanitize_for_log(type(modinput_type).__name__)}"
883
+ )
884
+ if type(stanza_name) is not str or not _is_safe_identifier_str(stanza_name):
885
+ raise TypeError(
886
+ "stanza_name must be a str without CR/LF, got "
887
+ f"{_sanitize_for_log(type(stanza_name).__name__)}"
888
+ )
657
889
  self._stanza_name = stanza_name
658
890
  self._service = self._get_or_create_service(modinput_type, logger)
659
891
  self._emit_zero_baseline()
@@ -744,11 +976,40 @@ class StanzaObservabilityRecorder:
744
976
  extra_attrs={"my_ta.partition": partition_id},
745
977
  )
746
978
  """
747
- attrs = dict(extra_attrs) if extra_attrs else {}
979
+ attrs = {}
980
+ if extra_attrs is not None:
981
+ if type(extra_attrs) is not dict:
982
+ self._logger.info(
983
+ "Ignoring extra_attrs: expected dict or None, got %s",
984
+ _sanitize_for_log(type(extra_attrs).__name__),
985
+ )
986
+ else:
987
+ for key, value in extra_attrs.items():
988
+ key_error = _attr_key_error(key)
989
+ if key_error is not None:
990
+ self._logger.info("Ignoring invalid attribute: %s", key_error)
991
+ continue
992
+ value_error = _attr_value_error(value)
993
+ if value_error is not None:
994
+ self._logger.info(
995
+ "Ignoring invalid value for attribute %r: %s",
996
+ key,
997
+ value_error,
998
+ )
999
+ continue
1000
+ attrs[key] = value
748
1001
  attrs[ATTR_MODINPUT_NAME] = self._stanza_name
749
- if self._service.event_count_counter:
1002
+
1003
+ event_count_error = _count_error(event_count)
1004
+ if event_count_error is not None:
1005
+ self._logger.info("Skipping invalid event_count: %s", event_count_error)
1006
+ elif self._service.event_count_counter:
750
1007
  self._service.event_count_counter.add(event_count, attributes=attrs)
751
- if self._service.event_bytes_counter:
1008
+
1009
+ byte_count_error = _count_error(byte_count)
1010
+ if byte_count_error is not None:
1011
+ self._logger.info("Skipping invalid byte_count: %s", byte_count_error)
1012
+ elif self._service.event_bytes_counter:
752
1013
  self._service.event_bytes_counter.add(byte_count, attributes=attrs)
753
1014
 
754
1015
  def flush(self) -> None:
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