beacon-profiling 1.2.0__tar.gz → 1.2.2__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 (58) hide show
  1. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/PKG-INFO +54 -6
  2. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/README.rst +49 -1
  3. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/pyproject.toml +4 -4
  4. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/collector/base.py +12 -0
  5. beacon_profiling-1.2.2/src/opentelemetry/sdk/extension/profiling/collector/buffering.py +207 -0
  6. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/collector/exception.py +26 -9
  7. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/collector/lock.py +172 -58
  8. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/collector/memory.py +54 -43
  9. beacon_profiling-1.2.2/src/opentelemetry/sdk/extension/profiling/collector/stack.py +167 -0
  10. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/context_bridge.py +41 -7
  11. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/environment_variables.py +5 -0
  12. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/pprof_http.py +41 -13
  13. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/model/builder.py +10 -6
  14. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/model/pprof_builder.py +4 -8
  15. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/runtime.py +79 -22
  16. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/scheduler.py +15 -2
  17. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/version.py +1 -1
  18. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_asyncio_lock_collector.py +9 -4
  19. beacon_profiling-1.2.2/tests/test_asyncio_lock_counts.py +102 -0
  20. beacon_profiling-1.2.2/tests/test_buffer_budget.py +257 -0
  21. beacon_profiling-1.2.2/tests/test_dictionary_contract.py +92 -0
  22. beacon_profiling-1.2.2/tests/test_local_root_context.py +156 -0
  23. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_lock_collector.py +16 -5
  24. beacon_profiling-1.2.2/tests/test_lock_sampling.py +161 -0
  25. beacon_profiling-1.2.2/tests/test_pprof_http_exporter.py +158 -0
  26. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_profiler_integration.py +4 -1
  27. beacon_profiling-1.2.2/tests/test_protobuf_environment.py +71 -0
  28. beacon_profiling-1.2.2/tests/test_scheduler_resilience.py +46 -0
  29. beacon_profiling-1.2.2/tests/test_stop_lifecycle.py +155 -0
  30. beacon_profiling-1.2.0/src/opentelemetry/sdk/extension/profiling/collector/stack.py +0 -152
  31. beacon_profiling-1.2.0/tests/test_pprof_http_exporter.py +0 -74
  32. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/.gitignore +0 -0
  33. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/LICENSE +0 -0
  34. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/__init__.py +0 -0
  35. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/bootstrap.py +0 -0
  36. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/collector/__init__.py +0 -0
  37. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/__init__.py +0 -0
  38. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/grpc.py +0 -0
  39. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/http.py +0 -0
  40. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/pprof.py +0 -0
  41. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/pprof_pb2.py +0 -0
  42. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/export/result.py +0 -0
  43. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/src/opentelemetry/sdk/extension/profiling/model/__init__.py +0 -0
  44. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_asyncio_context_bridge.py +0 -0
  45. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_auto_instrumentation_integration.py +0 -0
  46. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_bootstrap.py +0 -0
  47. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_builder.py +0 -0
  48. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_context_bridge.py +0 -0
  49. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_exception_collector.py +0 -0
  50. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_exporters.py +0 -0
  51. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_fork_runtime.py +0 -0
  52. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_memory_collector.py +0 -0
  53. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_package_metadata.py +0 -0
  54. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_pprof_builder.py +0 -0
  55. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_pprof_exporter.py +0 -0
  56. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_runtime_exporter_selection.py +0 -0
  57. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_runtime_scheduling.py +0 -0
  58. {beacon_profiling-1.2.0 → beacon_profiling-1.2.2}/tests/test_stack_collector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beacon-profiling
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Beacon Profiling for OpenTelemetry Python
5
5
  Project-URL: Homepage, https://github.com/beacon-observability/beacon-python/tree/main/sdk-extension/beacon-profiling
6
6
  Project-URL: Repository, https://github.com/beacon-observability/beacon-python
@@ -18,17 +18,17 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.10
21
- Requires-Dist: opentelemetry-api==1.44.0
22
- Requires-Dist: opentelemetry-exporter-otlp==1.44.0
23
- Requires-Dist: opentelemetry-instrumentation==0.65b0
24
- Requires-Dist: opentelemetry-sdk==1.44.0
21
+ Requires-Dist: opentelemetry-api==1.45.1
22
+ Requires-Dist: opentelemetry-exporter-otlp==1.45.1
23
+ Requires-Dist: opentelemetry-instrumentation==0.66b1
24
+ Requires-Dist: opentelemetry-sdk==1.45.1
25
25
  Requires-Dist: requests>=2.20
26
26
  Description-Content-Type: text/x-rst
27
27
 
28
28
  Beacon Profiling for Python
29
29
  ===========================
30
30
 
31
- The current stable version is ``beacon-profiling==1.1.0``.
31
+ The current stable version is ``beacon-profiling==1.2.1``.
32
32
 
33
33
  This package provides a Python profiling runtime for OpenTelemetry
34
34
  auto-instrumentation. The current implementation exports profiles via OTLP
@@ -44,6 +44,13 @@ or a compatible pprof endpoint and supports multiple collector types:
44
44
  Handled-exception collection requires Python ``sys.monitoring`` (Python
45
45
  3.12+); on Python 3.10/3.11 the other collectors remain available.
46
46
 
47
+ Trace-correlated samples retain the current span ID separately from the
48
+ local root span ID. Endpoint and span kind come from that local root, so a
49
+ database child span does not replace the request endpoint. Root identity
50
+ travels with the OpenTelemetry context across tasks and propagated threads.
51
+ If profiling starts inside a pre-existing local child span whose root was
52
+ not observed, the root and endpoint remain unknown.
53
+
47
54
  Usage
48
55
  -----
49
56
 
@@ -75,11 +82,17 @@ The following environment variables are supported:
75
82
  * ``OTEL_PROFILING_EXPORT_INTERVAL`` (defaults to 60 seconds and can be
76
83
  overridden by the ``Profiler(export_interval=...)`` argument)
77
84
  * ``OTEL_PROFILING_MAX_FRAMES``
85
+ * ``OTEL_PROFILING_MAX_BUFFER_SAMPLES`` (defaults to 10000)
86
+ * ``OTEL_PROFILING_MAX_BUFFER_BYTES`` (defaults to 8388608 accounted bytes)
78
87
  * ``OTEL_PROFILING_INCLUDE_TRACE_CONTEXT``
79
88
  * ``OTEL_PROFILING_EXCEPTION_ENABLED``
80
89
  * ``OTEL_PROFILING_EXCEPTION_SAMPLING_INTERVAL``
81
90
  * ``OTEL_PROFILING_EXCEPTION_COLLECT_MESSAGE``
82
91
  * ``OTEL_PROFILING_LOCK_ENABLED``
92
+ * ``OTEL_PROFILING_LOCK_SAMPLING_INTERVAL`` (defaults to 100; one in N
93
+ short acquisitions is sampled; 1 records every operation)
94
+ * ``OTEL_PROFILING_LOCK_MIN_DURATION_NS`` (defaults to 1000000; operations
95
+ at or above this duration are recorded without probabilistic sampling)
83
96
  * ``OTEL_PROFILING_MEMORY_ENABLED``
84
97
  * ``OTEL_PROFILING_MEMORY_INTERVAL`` (defaults to the profile export interval
85
98
  and can be overridden by the ``Profiler(memory_interval=...)`` argument)
@@ -94,6 +107,8 @@ The following environment variables are supported:
94
107
  * ``OTEL_EXPORTER_OTLP_PROFILES_CLIENT_KEY``
95
108
  * ``OTEL_EXPORTER_OTLP_PROFILES_CLIENT_CERTIFICATE``
96
109
  * ``OTEL_PROFILING_PPROF_PATH``
110
+ * ``OTEL_PROFILING_PPROF_MAX_FILES`` (HTTP upload local copies only; defaults
111
+ to 3 retained files per exporter instance)
97
112
  * ``OTEL_PROFILING_PPROF_UPLOAD_URL``
98
113
  * ``OTEL_PROFILING_PPROF_HEADERS``
99
114
 
@@ -101,6 +116,28 @@ Service metadata should be configured with standard OpenTelemetry
101
116
  resource settings such as ``OTEL_SERVICE_NAME`` and
102
117
  ``OTEL_RESOURCE_ATTRIBUTES``.
103
118
 
119
+ Short lock acquisition/hold pairs share one sampling decision. Slow holds
120
+ and waits are retained even when their acquisition was not sampled. Sampling
121
+ is decided before stack walking and trace lookup; filename normalization
122
+ uses a bounded cache. OTLP samples carry their interval as ``period`` and
123
+ ``lock.sampling.interval``. Compatible pprof lock counts and durations are
124
+ weighted by that interval; they are estimates when the interval exceeds 1.
125
+
126
+ One profiler-wide budget covers raw samples in all collectors and the
127
+ pending export buffer. Both limits also accept ``Profiler(max_buffer_samples=,
128
+ max_buffer_bytes=)`` arguments. Excess samples are dropped, counted in
129
+ ``profiler.buffer_statistics``, and reported in a warning at flush. The next
130
+ window can collect again after draining; delivery is bounded, not lossless.
131
+ Retained samples preserve their individual timestamps, trace identity, values,
132
+ and CPU deltas. Immutable stack frames use a bounded cache.
133
+
134
+ Byte accounting conservatively includes sample metadata and frame references,
135
+ including shared objects. It bounds queued raw data, not total process RSS;
136
+ heap snapshots and serialization also need temporary memory. Capture/build/
137
+ export work is excluded from the profiler's own exception and lock collection.
138
+ ``memory_interval=0`` keeps manual memory capture available on each call;
139
+ periodic memory scheduling uses the positive export cadence instead of spinning.
140
+
104
141
  Examples
105
142
  --------
106
143
 
@@ -131,8 +168,19 @@ copy:
131
168
 
132
169
  export OTEL_PROFILING_PPROF_UPLOAD_URL=http://localhost:9529/profiling/v1/input
133
170
  export OTEL_PROFILING_PPROF_HEADERS="X-API-Key:xxx"
171
+ export OTEL_PROFILING_PPROF_PATH=/tmp/otel-profile
134
172
  beacon python app.py
135
173
 
174
+ HTTP upload does not write local files unless ``OTEL_PROFILING_PPROF_PATH``
175
+ is set. An empty ``pprof_path`` argument disables copies even when that
176
+ environment variable is set. Local write/cleanup failures are logged and do
177
+ not prevent upload. Retention removes only files written by this exporter
178
+ instance; it never scans or removes files owned by other processes. The
179
+ file-only ``pprof`` exporter retains its existing output behavior.
180
+
181
+ Capture and export exceptions are logged; subsequent scheduled captures
182
+ and export attempts continue. Failed batches are not replayed automatically.
183
+
136
184
  When ``OTEL_PROFILING_EXPORTER`` is unset and
137
185
  ``OTEL_PROFILING_PPROF_UPLOAD_URL`` is configured, the runtime
138
186
  defaults to a legacy-compatible ``pprof`` upload layout. Set
@@ -1,7 +1,7 @@
1
1
  Beacon Profiling for Python
2
2
  ===========================
3
3
 
4
- The current stable version is ``beacon-profiling==1.1.0``.
4
+ The current stable version is ``beacon-profiling==1.2.1``.
5
5
 
6
6
  This package provides a Python profiling runtime for OpenTelemetry
7
7
  auto-instrumentation. The current implementation exports profiles via OTLP
@@ -17,6 +17,13 @@ or a compatible pprof endpoint and supports multiple collector types:
17
17
  Handled-exception collection requires Python ``sys.monitoring`` (Python
18
18
  3.12+); on Python 3.10/3.11 the other collectors remain available.
19
19
 
20
+ Trace-correlated samples retain the current span ID separately from the
21
+ local root span ID. Endpoint and span kind come from that local root, so a
22
+ database child span does not replace the request endpoint. Root identity
23
+ travels with the OpenTelemetry context across tasks and propagated threads.
24
+ If profiling starts inside a pre-existing local child span whose root was
25
+ not observed, the root and endpoint remain unknown.
26
+
20
27
  Usage
21
28
  -----
22
29
 
@@ -48,11 +55,17 @@ The following environment variables are supported:
48
55
  * ``OTEL_PROFILING_EXPORT_INTERVAL`` (defaults to 60 seconds and can be
49
56
  overridden by the ``Profiler(export_interval=...)`` argument)
50
57
  * ``OTEL_PROFILING_MAX_FRAMES``
58
+ * ``OTEL_PROFILING_MAX_BUFFER_SAMPLES`` (defaults to 10000)
59
+ * ``OTEL_PROFILING_MAX_BUFFER_BYTES`` (defaults to 8388608 accounted bytes)
51
60
  * ``OTEL_PROFILING_INCLUDE_TRACE_CONTEXT``
52
61
  * ``OTEL_PROFILING_EXCEPTION_ENABLED``
53
62
  * ``OTEL_PROFILING_EXCEPTION_SAMPLING_INTERVAL``
54
63
  * ``OTEL_PROFILING_EXCEPTION_COLLECT_MESSAGE``
55
64
  * ``OTEL_PROFILING_LOCK_ENABLED``
65
+ * ``OTEL_PROFILING_LOCK_SAMPLING_INTERVAL`` (defaults to 100; one in N
66
+ short acquisitions is sampled; 1 records every operation)
67
+ * ``OTEL_PROFILING_LOCK_MIN_DURATION_NS`` (defaults to 1000000; operations
68
+ at or above this duration are recorded without probabilistic sampling)
56
69
  * ``OTEL_PROFILING_MEMORY_ENABLED``
57
70
  * ``OTEL_PROFILING_MEMORY_INTERVAL`` (defaults to the profile export interval
58
71
  and can be overridden by the ``Profiler(memory_interval=...)`` argument)
@@ -67,6 +80,8 @@ The following environment variables are supported:
67
80
  * ``OTEL_EXPORTER_OTLP_PROFILES_CLIENT_KEY``
68
81
  * ``OTEL_EXPORTER_OTLP_PROFILES_CLIENT_CERTIFICATE``
69
82
  * ``OTEL_PROFILING_PPROF_PATH``
83
+ * ``OTEL_PROFILING_PPROF_MAX_FILES`` (HTTP upload local copies only; defaults
84
+ to 3 retained files per exporter instance)
70
85
  * ``OTEL_PROFILING_PPROF_UPLOAD_URL``
71
86
  * ``OTEL_PROFILING_PPROF_HEADERS``
72
87
 
@@ -74,6 +89,28 @@ Service metadata should be configured with standard OpenTelemetry
74
89
  resource settings such as ``OTEL_SERVICE_NAME`` and
75
90
  ``OTEL_RESOURCE_ATTRIBUTES``.
76
91
 
92
+ Short lock acquisition/hold pairs share one sampling decision. Slow holds
93
+ and waits are retained even when their acquisition was not sampled. Sampling
94
+ is decided before stack walking and trace lookup; filename normalization
95
+ uses a bounded cache. OTLP samples carry their interval as ``period`` and
96
+ ``lock.sampling.interval``. Compatible pprof lock counts and durations are
97
+ weighted by that interval; they are estimates when the interval exceeds 1.
98
+
99
+ One profiler-wide budget covers raw samples in all collectors and the
100
+ pending export buffer. Both limits also accept ``Profiler(max_buffer_samples=,
101
+ max_buffer_bytes=)`` arguments. Excess samples are dropped, counted in
102
+ ``profiler.buffer_statistics``, and reported in a warning at flush. The next
103
+ window can collect again after draining; delivery is bounded, not lossless.
104
+ Retained samples preserve their individual timestamps, trace identity, values,
105
+ and CPU deltas. Immutable stack frames use a bounded cache.
106
+
107
+ Byte accounting conservatively includes sample metadata and frame references,
108
+ including shared objects. It bounds queued raw data, not total process RSS;
109
+ heap snapshots and serialization also need temporary memory. Capture/build/
110
+ export work is excluded from the profiler's own exception and lock collection.
111
+ ``memory_interval=0`` keeps manual memory capture available on each call;
112
+ periodic memory scheduling uses the positive export cadence instead of spinning.
113
+
77
114
  Examples
78
115
  --------
79
116
 
@@ -104,8 +141,19 @@ copy:
104
141
 
105
142
  export OTEL_PROFILING_PPROF_UPLOAD_URL=http://localhost:9529/profiling/v1/input
106
143
  export OTEL_PROFILING_PPROF_HEADERS="X-API-Key:xxx"
144
+ export OTEL_PROFILING_PPROF_PATH=/tmp/otel-profile
107
145
  beacon python app.py
108
146
 
147
+ HTTP upload does not write local files unless ``OTEL_PROFILING_PPROF_PATH``
148
+ is set. An empty ``pprof_path`` argument disables copies even when that
149
+ environment variable is set. Local write/cleanup failures are logged and do
150
+ not prevent upload. Retention removes only files written by this exporter
151
+ instance; it never scans or removes files owned by other processes. The
152
+ file-only ``pprof`` exporter retains its existing output behavior.
153
+
154
+ Capture and export exceptions are logged; subsequent scheduled captures
155
+ and export attempts continue. Failed batches are not replayed automatically.
156
+
109
157
  When ``OTEL_PROFILING_EXPORTER`` is unset and
110
158
  ``OTEL_PROFILING_PPROF_UPLOAD_URL`` is configured, the runtime
111
159
  defaults to a legacy-compatible ``pprof`` upload layout. Set
@@ -25,10 +25,10 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3.14",
26
26
  ]
27
27
  dependencies = [
28
- "opentelemetry-api == 1.44.0",
29
- "opentelemetry-sdk == 1.44.0",
30
- "opentelemetry-instrumentation == 0.65b0",
31
- "opentelemetry-exporter-otlp == 1.44.0",
28
+ "opentelemetry-api == 1.45.1",
29
+ "opentelemetry-sdk == 1.45.1",
30
+ "opentelemetry-instrumentation == 0.66b1",
31
+ "opentelemetry-exporter-otlp == 1.45.1",
32
32
  "requests >= 2.20",
33
33
  ]
34
34
 
@@ -15,6 +15,7 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  from dataclasses import dataclass
18
+ from functools import lru_cache
18
19
  from typing import Protocol
19
20
 
20
21
 
@@ -25,6 +26,17 @@ class CapturedFrame:
25
26
  lineno: int
26
27
 
27
28
 
29
+ @lru_cache(maxsize=4096)
30
+ def _cached_frame(function: str, filename: str, lineno: int) -> CapturedFrame:
31
+ return CapturedFrame(function, filename, lineno)
32
+
33
+
34
+ def captured_frame(function: str, filename: str, lineno: int) -> CapturedFrame:
35
+ if len(function) <= 512 and len(filename) <= 2048:
36
+ return _cached_frame(function, filename, lineno)
37
+ return CapturedFrame(function, filename, lineno)
38
+
39
+
28
40
  @dataclass(frozen=True, slots=True)
29
41
  class CapturedAttribute:
30
42
  key: str
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from _thread import allocate_lock
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import fields
8
+ from functools import lru_cache
9
+ from os import environ
10
+ from typing import Iterator
11
+
12
+ from opentelemetry.sdk.extension.profiling.collector.base import (
13
+ CapturedAttribute,
14
+ CapturedFrame,
15
+ CapturedSample,
16
+ )
17
+
18
+ _INTERNAL = ContextVar("beacon_profiling_internal_work", default=False)
19
+ _FIELD_NAMES = {
20
+ cls: tuple(field.name for field in fields(cls))
21
+ for cls in (CapturedSample, CapturedAttribute)
22
+ }
23
+
24
+
25
+ @lru_cache(maxsize=4096)
26
+ def _frame_size(frame: CapturedFrame) -> int:
27
+ return (
28
+ sys.getsizeof(frame)
29
+ + sys.getsizeof(frame.function)
30
+ + sys.getsizeof(frame.filename)
31
+ + sys.getsizeof(frame.lineno)
32
+ )
33
+
34
+
35
+ def internal_work() -> bool:
36
+ return _INTERNAL.get()
37
+
38
+
39
+ @contextmanager
40
+ def suppress_collection() -> Iterator[None]:
41
+ token = _INTERNAL.set(True)
42
+ try:
43
+ yield
44
+ finally:
45
+ _INTERNAL.reset(token)
46
+
47
+
48
+ def _sample_size(sample: CapturedSample, limit: int) -> int:
49
+ # Charge shared frame/string references conservatively per sample. The
50
+ # budget bounds producer-owned raw data, not application heap snapshots
51
+ # or temporary serialized messages. Never invoke user __sizeof__ hooks.
52
+ pending: list[object] = [sample]
53
+ seen: set[int] = set()
54
+ total = 96 # Buffer entry and accounting overhead.
55
+ while pending:
56
+ value = pending.pop()
57
+ if id(value) in seen:
58
+ continue
59
+ seen.add(id(value))
60
+ if len(seen) > 4096:
61
+ return limit + 1
62
+ cls = type(value)
63
+ if cls is CapturedFrame:
64
+ if (
65
+ type(value.function) is not str
66
+ or type(value.filename) is not str
67
+ or type(value.lineno) is not int
68
+ ):
69
+ return limit + 1
70
+ if len(value.function) <= 512 and len(value.filename) <= 2048:
71
+ total += _frame_size(value)
72
+ else:
73
+ total += sum(
74
+ sys.getsizeof(item)
75
+ for item in (
76
+ value,
77
+ value.function,
78
+ value.filename,
79
+ value.lineno,
80
+ )
81
+ )
82
+ if total > limit:
83
+ return total
84
+ continue
85
+ if cls is CapturedSample or cls is CapturedAttribute:
86
+ pending.extend(getattr(value, name) for name in _FIELD_NAMES[cls])
87
+ elif cls is tuple or cls is list:
88
+ if len(value) > 4096:
89
+ return limit + 1
90
+ pending.extend(value)
91
+ elif cls is dict:
92
+ if len(value) > 2048:
93
+ return limit + 1
94
+ pending.extend(value.keys())
95
+ pending.extend(value.values())
96
+ elif not any(
97
+ cls is scalar
98
+ for scalar in (str, bytes, bytearray, int, float, bool, type(None))
99
+ ):
100
+ return limit + 1
101
+ total += sys.getsizeof(value)
102
+ if total > limit:
103
+ return total
104
+ return total
105
+
106
+
107
+ class SampleBudget:
108
+ def __init__(
109
+ self, max_samples: int | None = None, max_bytes: int | None = None
110
+ ) -> None:
111
+ self.max_samples = (
112
+ max_samples
113
+ if max_samples is not None
114
+ else int(environ.get("OTEL_PROFILING_MAX_BUFFER_SAMPLES", "10000"))
115
+ )
116
+ self.max_bytes = (
117
+ max_bytes
118
+ if max_bytes is not None
119
+ else int(
120
+ environ.get(
121
+ "OTEL_PROFILING_MAX_BUFFER_BYTES", str(8 * 1024 * 1024)
122
+ )
123
+ )
124
+ )
125
+ if self.max_samples < 1 or self.max_bytes < 1:
126
+ raise ValueError("profile buffer limits must be positive")
127
+ self.reset_after_fork()
128
+
129
+ def reset_after_fork(self) -> None:
130
+ self._lock = allocate_lock()
131
+ self.generation = getattr(self, "generation", 0) + 1
132
+ self._used_samples = 0
133
+ self._used_bytes = 0
134
+ self._dropped_samples = 0
135
+
136
+ def reserve(self, sample: CapturedSample, generation: int) -> int | None:
137
+ size = _sample_size(sample, self.max_bytes)
138
+ with self._lock:
139
+ if (
140
+ generation != self.generation
141
+ or self._used_samples >= self.max_samples
142
+ or self._used_bytes + size > self.max_bytes
143
+ ):
144
+ self._dropped_samples += 1
145
+ return None
146
+ self._used_samples += 1
147
+ self._used_bytes += size
148
+ return size
149
+
150
+ def release(self, count: int, size: int, generation: int) -> None:
151
+ with self._lock:
152
+ if generation != self.generation:
153
+ return
154
+ self._used_samples -= count
155
+ self._used_bytes -= size
156
+
157
+ def snapshot(self) -> dict[str, int]:
158
+ with self._lock:
159
+ return {
160
+ "max_samples": self.max_samples,
161
+ "max_bytes": self.max_bytes,
162
+ "used_samples": self._used_samples,
163
+ "used_bytes": self._used_bytes,
164
+ "dropped_samples": self._dropped_samples,
165
+ }
166
+
167
+
168
+ class SampleBuffer:
169
+ """Caller-locked buffer sharing a single profiler-wide raw-data budget."""
170
+
171
+ def __init__(self, budget: SampleBudget) -> None:
172
+ self._budget = budget
173
+ self._generation = budget.generation
174
+ self._items: list[tuple[CapturedSample, int]] = []
175
+
176
+ def __len__(self) -> int:
177
+ return len(self._items)
178
+
179
+ def append(self, sample: CapturedSample) -> None:
180
+ size = self._budget.reserve(sample, self._generation)
181
+ if size is not None:
182
+ try:
183
+ self._items.append((sample, size))
184
+ except BaseException:
185
+ self._budget.release(1, size, self._generation)
186
+ raise
187
+
188
+ def extend(self, samples: list[CapturedSample]) -> None:
189
+ for sample in samples:
190
+ self.append(sample)
191
+
192
+ def drain(self) -> list[CapturedSample]:
193
+ result = [sample for sample, _ in self._items]
194
+ self.clear()
195
+ return result
196
+
197
+ def clear(self) -> None:
198
+ items, self._items = self._items, []
199
+ self._budget.release(
200
+ len(items), sum(size for _, size in items), self._generation
201
+ )
202
+
203
+ def __enter__(self) -> SampleBuffer:
204
+ return self
205
+
206
+ def __exit__(self, *_: object) -> None:
207
+ self.clear()
@@ -27,6 +27,12 @@ from opentelemetry.sdk.extension.profiling.collector.base import (
27
27
  CapturedAttribute,
28
28
  CapturedFrame,
29
29
  CapturedSample,
30
+ captured_frame,
31
+ )
32
+ from opentelemetry.sdk.extension.profiling.collector.buffering import (
33
+ SampleBudget,
34
+ SampleBuffer,
35
+ internal_work,
30
36
  )
31
37
  from opentelemetry.sdk.extension.profiling.context_bridge import ContextBridge
32
38
  from opentelemetry.trace import INVALID_SPAN_CONTEXT
@@ -66,17 +72,22 @@ class ExceptionCollector:
66
72
  include_trace_context: bool = True,
67
73
  sampling_interval: int = 100,
68
74
  collect_message: bool = False,
75
+ sample_budget: SampleBudget | None = None,
69
76
  ) -> None:
70
77
  if sampling_interval < 1:
71
78
  raise ValueError("sampling_interval must be >= 1")
72
79
 
80
+ self._owns_sample_budget = sample_budget is None
81
+ self._sample_budget = (
82
+ sample_budget if sample_budget is not None else SampleBudget()
83
+ )
73
84
  self._context_bridge = context_bridge
74
85
  self._max_frames = max_frames
75
86
  self._include_trace_context = include_trace_context
76
87
  self._sampling_interval = sampling_interval
77
88
  self._collect_message = collect_message
78
89
 
79
- self._buffer: list[CapturedSample] = []
90
+ self._buffer = SampleBuffer(self._sample_budget)
80
91
  self._buffer_lock = threading.Lock()
81
92
  self._sampler = _PoissonSampler()
82
93
  self._counter = 0
@@ -134,24 +145,26 @@ class ExceptionCollector:
134
145
 
135
146
  def stop(self) -> None:
136
147
  tool_id = self._monitoring_tool_id
148
+ with self._buffer_lock:
149
+ self._monitoring_tool_id = None
150
+ self._buffer.clear()
137
151
  if tool_id is None:
138
152
  return
139
153
 
140
154
  self._cleanup_monitoring(tool_id)
141
- self._monitoring_tool_id = None
142
155
 
143
156
  def capture(self) -> list[CapturedSample]:
144
157
  with self._buffer_lock:
145
- captured = self._buffer
146
- self._buffer = []
147
- return captured
158
+ return self._buffer.drain()
148
159
 
149
160
  def reset_after_fork(self) -> None:
150
161
  tool_id = self._monitoring_tool_id
151
162
  if tool_id is not None:
152
163
  self._cleanup_monitoring(tool_id)
153
164
  self._buffer_lock = threading.Lock()
154
- self._buffer = []
165
+ if self._owns_sample_budget:
166
+ self._sample_budget.reset_after_fork()
167
+ self._buffer = SampleBuffer(self._sample_budget)
155
168
  self._counter = 0
156
169
  self._next_sample = self._sampling_interval
157
170
  self._collecting_state = threading.local()
@@ -197,7 +210,10 @@ class ExceptionCollector:
197
210
  ) -> None:
198
211
  del code, instruction_offset
199
212
 
200
- if self._is_collecting():
213
+ if self._is_collecting() or internal_work():
214
+ return
215
+ tool_id = self._monitoring_tool_id
216
+ if tool_id is None:
201
217
  return
202
218
 
203
219
  self._counter += 1
@@ -213,7 +229,8 @@ class ExceptionCollector:
213
229
  if sample is None:
214
230
  return
215
231
  with self._buffer_lock:
216
- self._buffer.append(sample)
232
+ if self._monitoring_tool_id == tool_id:
233
+ self._buffer.append(sample)
217
234
  except Exception:
218
235
  _logger.debug("Failed to capture handled exception", exc_info=True)
219
236
  finally:
@@ -287,7 +304,7 @@ class ExceptionCollector:
287
304
  while current is not None:
288
305
  frame = current.tb_frame
289
306
  frames.append(
290
- CapturedFrame(
307
+ captured_frame(
291
308
  function=frame.f_code.co_name,
292
309
  filename=frame.f_code.co_filename,
293
310
  lineno=current.tb_lineno,