beacon-profiling 1.2.6__tar.gz → 1.2.7__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 (57) hide show
  1. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/PKG-INFO +21 -18
  2. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/README.rst +20 -17
  3. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/environment_variables.py +5 -0
  4. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/export/__init__.py +4 -1
  5. beacon_profiling-1.2.7/src/opentelemetry/sdk/extension/profiling/export/pprof_http.py +313 -0
  6. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/runtime.py +19 -4
  7. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/version.py +1 -1
  8. beacon_profiling-1.2.7/tests/test_compatible_runtime_http.py +75 -0
  9. beacon_profiling-1.2.7/tests/test_pprof_http_exporter.py +158 -0
  10. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_runtime_exporter_selection.py +20 -10
  11. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/.gitignore +0 -0
  12. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/LICENSE +0 -0
  13. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/pyproject.toml +0 -0
  14. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/__init__.py +0 -0
  15. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/bootstrap.py +0 -0
  16. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/__init__.py +0 -0
  17. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/base.py +0 -0
  18. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/buffering.py +0 -0
  19. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/exception.py +0 -0
  20. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/lock.py +0 -0
  21. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/memory.py +0 -0
  22. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/collector/stack.py +0 -0
  23. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/context_bridge.py +0 -0
  24. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/export/file.py +0 -0
  25. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/export/grpc.py +0 -0
  26. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/export/http.py +0 -0
  27. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/export/pprof_pb2.py +0 -0
  28. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/export/result.py +0 -0
  29. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/model/__init__.py +0 -0
  30. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/model/builder.py +0 -0
  31. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/model/pprof_builder.py +0 -0
  32. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/src/opentelemetry/sdk/extension/profiling/scheduler.py +0 -0
  33. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_asyncio_context_bridge.py +0 -0
  34. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_asyncio_lock_collector.py +0 -0
  35. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_asyncio_lock_counts.py +0 -0
  36. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_auto_instrumentation_integration.py +0 -0
  37. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_bootstrap.py +0 -0
  38. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_buffer_budget.py +0 -0
  39. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_builder.py +0 -0
  40. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_context_bridge.py +0 -0
  41. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_dictionary_contract.py +0 -0
  42. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_exception_collector.py +0 -0
  43. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_exporters.py +0 -0
  44. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_fork_runtime.py +0 -0
  45. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_local_root_context.py +0 -0
  46. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_lock_collector.py +0 -0
  47. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_lock_sampling.py +0 -0
  48. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_memory_collector.py +0 -0
  49. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_package_metadata.py +0 -0
  50. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_pprof_builder.py +0 -0
  51. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_pprof_exporter.py +0 -0
  52. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_profiler_integration.py +0 -0
  53. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_protobuf_environment.py +0 -0
  54. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_runtime_scheduling.py +0 -0
  55. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_scheduler_resilience.py +0 -0
  56. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_stack_collector.py +0 -0
  57. {beacon_profiling-1.2.6 → beacon_profiling-1.2.7}/tests/test_stop_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beacon-profiling
3
- Version: 1.2.6
3
+ Version: 1.2.7
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
@@ -77,7 +77,12 @@ Configuration
77
77
  The following environment variables are supported:
78
78
 
79
79
  * ``OTEL_PROFILING_ENABLED``
80
- * ``OTEL_PROFILING_EXPORTER`` (``otlp`` or ``file``; defaults to ``otlp``)
80
+ * ``OTEL_PROFILING_EXPORTER`` (``http`` or ``file``; defaults to ``http``;
81
+ legacy ``datakit`` and ``pprof_http`` names remain aliases)
82
+ * ``OTEL_PROFILING_ENDPOINT`` (defaults to
83
+ ``http://127.0.0.1:9529/profiling/v1/input``)
84
+ * ``OTEL_PROFILING_PPROF_UPLOAD_URL`` (legacy explicit endpoint; takes precedence)
85
+ * ``OTEL_PROFILING_PPROF_HEADERS`` (legacy upload headers)
81
86
  * ``OTEL_PROFILING_SAMPLE_INTERVAL``
82
87
  * ``OTEL_PROFILING_EXPORT_INTERVAL`` (defaults to 60 seconds and can be
83
88
  overridden by the ``Profiler(export_interval=...)`` argument)
@@ -137,8 +142,8 @@ periodic memory scheduling uses the positive export cadence instead of spinning.
137
142
  Examples
138
143
  --------
139
144
 
140
- Collect stack, exception, lock, and heap profiles and export them with
141
- OTLP/HTTP:
145
+ Collect stack, exception, lock, and heap profiles and upload them over HTTP
146
+ using the existing DataKit-compatible multipart/pprof format:
142
147
 
143
148
  ::
144
149
 
@@ -146,11 +151,11 @@ OTLP/HTTP:
146
151
  export OTEL_PROFILING_EXCEPTION_ENABLED=true
147
152
  export OTEL_PROFILING_LOCK_ENABLED=true
148
153
  export OTEL_PROFILING_MEMORY_ENABLED=true
149
- export OTEL_EXPORTER_OTLP_PROFILES_PROTOCOL=http/protobuf
150
- export OTEL_EXPORTER_OTLP_PROFILES_ENDPOINT=http://localhost:4318/v1development/profiles
154
+ export OTEL_PROFILING_EXPORTER=http
155
+ export OTEL_PROFILING_ENDPOINT=http://127.0.0.1:9529/profiling/v1/input
151
156
  beacon python app.py
152
157
 
153
- To dump pprof files instead of OTLP, set:
158
+ To dump pprof files instead of uploading them, set:
154
159
 
155
160
  ::
156
161
 
@@ -160,15 +165,13 @@ To dump pprof files instead of OTLP, set:
160
165
  The ``file`` exporter ignores all OTLP endpoint and transport settings.
161
166
  It writes gzip pprof files to the configured directory, without network uploads.
162
167
 
163
- For OTLP/gRPC, set::
168
+ The ``http`` configuration name selects the existing multipart/pprof uploader.
169
+ The ``otlp`` exporter name is reserved for future OTLP Profiles support and is
170
+ currently rejected by environment-based selection. Sampling and profile encoding
171
+ are unchanged. This uploader does not inherit the trace exporter's protocol or shared
172
+ OTLP endpoint. A gRPC traces endpoint is not a profiling upload endpoint.
164
173
 
165
- export OTEL_PROFILING_EXPORTER=otlp
166
- export OTEL_EXPORTER_OTLP_PROFILES_PROTOCOL=grpc
167
- export OTEL_EXPORTER_OTLP_PROFILES_ENDPOINT=http://localhost:4317
168
-
169
- OTLP/HTTP uses ``http/protobuf`` and an endpoint such as
170
- ``http://localhost:4318/v1development/profiles``. Profile-specific OTLP
171
- settings take precedence over shared ``OTEL_EXPORTER_OTLP_*`` settings.
172
- Only ``otlp`` and ``file`` exporter identifiers are accepted; disable
173
- profiling with ``OTEL_PROFILING_ENABLED=false``. These source changes
174
- require a new Beacon release. The previous multipart pprof exporter is removed.
174
+ The ``OTEL_EXPORTER_OTLP_PROFILES_*`` settings above apply only when explicitly
175
+ constructing the low-level OTLP exporters; the environment-selected compatible
176
+ uploader uses ``OTEL_PROFILING_ENDPOINT``. These source changes require a new
177
+ Beacon release.
@@ -50,7 +50,12 @@ Configuration
50
50
  The following environment variables are supported:
51
51
 
52
52
  * ``OTEL_PROFILING_ENABLED``
53
- * ``OTEL_PROFILING_EXPORTER`` (``otlp`` or ``file``; defaults to ``otlp``)
53
+ * ``OTEL_PROFILING_EXPORTER`` (``http`` or ``file``; defaults to ``http``;
54
+ legacy ``datakit`` and ``pprof_http`` names remain aliases)
55
+ * ``OTEL_PROFILING_ENDPOINT`` (defaults to
56
+ ``http://127.0.0.1:9529/profiling/v1/input``)
57
+ * ``OTEL_PROFILING_PPROF_UPLOAD_URL`` (legacy explicit endpoint; takes precedence)
58
+ * ``OTEL_PROFILING_PPROF_HEADERS`` (legacy upload headers)
54
59
  * ``OTEL_PROFILING_SAMPLE_INTERVAL``
55
60
  * ``OTEL_PROFILING_EXPORT_INTERVAL`` (defaults to 60 seconds and can be
56
61
  overridden by the ``Profiler(export_interval=...)`` argument)
@@ -110,8 +115,8 @@ periodic memory scheduling uses the positive export cadence instead of spinning.
110
115
  Examples
111
116
  --------
112
117
 
113
- Collect stack, exception, lock, and heap profiles and export them with
114
- OTLP/HTTP:
118
+ Collect stack, exception, lock, and heap profiles and upload them over HTTP
119
+ using the existing DataKit-compatible multipart/pprof format:
115
120
 
116
121
  ::
117
122
 
@@ -119,11 +124,11 @@ OTLP/HTTP:
119
124
  export OTEL_PROFILING_EXCEPTION_ENABLED=true
120
125
  export OTEL_PROFILING_LOCK_ENABLED=true
121
126
  export OTEL_PROFILING_MEMORY_ENABLED=true
122
- export OTEL_EXPORTER_OTLP_PROFILES_PROTOCOL=http/protobuf
123
- export OTEL_EXPORTER_OTLP_PROFILES_ENDPOINT=http://localhost:4318/v1development/profiles
127
+ export OTEL_PROFILING_EXPORTER=http
128
+ export OTEL_PROFILING_ENDPOINT=http://127.0.0.1:9529/profiling/v1/input
124
129
  beacon python app.py
125
130
 
126
- To dump pprof files instead of OTLP, set:
131
+ To dump pprof files instead of uploading them, set:
127
132
 
128
133
  ::
129
134
 
@@ -133,15 +138,13 @@ To dump pprof files instead of OTLP, set:
133
138
  The ``file`` exporter ignores all OTLP endpoint and transport settings.
134
139
  It writes gzip pprof files to the configured directory, without network uploads.
135
140
 
136
- For OTLP/gRPC, set::
141
+ The ``http`` configuration name selects the existing multipart/pprof uploader.
142
+ The ``otlp`` exporter name is reserved for future OTLP Profiles support and is
143
+ currently rejected by environment-based selection. Sampling and profile encoding
144
+ are unchanged. This uploader does not inherit the trace exporter's protocol or shared
145
+ OTLP endpoint. A gRPC traces endpoint is not a profiling upload endpoint.
137
146
 
138
- export OTEL_PROFILING_EXPORTER=otlp
139
- export OTEL_EXPORTER_OTLP_PROFILES_PROTOCOL=grpc
140
- export OTEL_EXPORTER_OTLP_PROFILES_ENDPOINT=http://localhost:4317
141
-
142
- OTLP/HTTP uses ``http/protobuf`` and an endpoint such as
143
- ``http://localhost:4318/v1development/profiles``. Profile-specific OTLP
144
- settings take precedence over shared ``OTEL_EXPORTER_OTLP_*`` settings.
145
- Only ``otlp`` and ``file`` exporter identifiers are accepted; disable
146
- profiling with ``OTEL_PROFILING_ENABLED=false``. These source changes
147
- require a new Beacon release. The previous multipart pprof exporter is removed.
147
+ The ``OTEL_EXPORTER_OTLP_PROFILES_*`` settings above apply only when explicitly
148
+ constructing the low-level OTLP exporters; the environment-selected compatible
149
+ uploader uses ``OTEL_PROFILING_ENDPOINT``. These source changes require a new
150
+ Beacon release.
@@ -35,6 +35,11 @@ OTEL_PROFILING_MEMORY_INTERVAL = "OTEL_PROFILING_MEMORY_INTERVAL"
35
35
  OTEL_PROFILING_MEMORY_TOP_STATS = "OTEL_PROFILING_MEMORY_TOP_STATS"
36
36
  OTEL_PROFILING_MEMORY_IGNORE_PROFILER = "OTEL_PROFILING_MEMORY_IGNORE_PROFILER"
37
37
  OTEL_PROFILING_FILE_PATH = "OTEL_PROFILING_FILE_PATH"
38
+ OTEL_PROFILING_ENDPOINT = "OTEL_PROFILING_ENDPOINT"
39
+ OTEL_PROFILING_PPROF_PATH = "OTEL_PROFILING_PPROF_PATH"
40
+ OTEL_PROFILING_PPROF_MAX_FILES = "OTEL_PROFILING_PPROF_MAX_FILES"
41
+ OTEL_PROFILING_PPROF_UPLOAD_URL = "OTEL_PROFILING_PPROF_UPLOAD_URL"
42
+ OTEL_PROFILING_PPROF_HEADERS = "OTEL_PROFILING_PPROF_HEADERS"
38
43
 
39
44
  OTEL_EXPORTER_OTLP_PROFILES_PROTOCOL = "OTEL_EXPORTER_OTLP_PROFILES_PROTOCOL"
40
45
  OTEL_EXPORTER_OTLP_PROFILES_ENDPOINT = "OTEL_EXPORTER_OTLP_PROFILES_ENDPOINT"
@@ -12,8 +12,11 @@ from opentelemetry.sdk.extension.profiling.export.grpc import (
12
12
  from opentelemetry.sdk.extension.profiling.export.http import (
13
13
  OTLPHTTPProfileExporter,
14
14
  )
15
+ from opentelemetry.sdk.extension.profiling.export.pprof_http import (
16
+ PPROFHTTPExporter as CompatiblePPROFExporter,
17
+ )
15
18
 
16
- __all__ = ("create_otlp_profile_exporter",)
19
+ __all__ = ("CompatiblePPROFExporter", "create_otlp_profile_exporter")
17
20
 
18
21
 
19
22
  def create_otlp_profile_exporter():
@@ -0,0 +1,313 @@
1
+ from __future__ import annotations
2
+
3
+ import binascii
4
+ import datetime
5
+ import json
6
+ import logging
7
+ import os
8
+ import platform
9
+ from collections import deque
10
+ from itertools import count
11
+ from os import environ
12
+ from typing import Dict, Optional
13
+ from uuid import uuid4
14
+
15
+ import requests
16
+
17
+ from opentelemetry.sdk.extension.profiling.environment_variables import (
18
+ OTEL_PROFILING_ENDPOINT,
19
+ OTEL_PROFILING_PPROF_HEADERS,
20
+ OTEL_PROFILING_PPROF_MAX_FILES,
21
+ OTEL_PROFILING_PPROF_PATH,
22
+ OTEL_PROFILING_PPROF_UPLOAD_URL,
23
+ )
24
+ from opentelemetry.sdk.extension.profiling.export.result import (
25
+ ProfileExportResult,
26
+ )
27
+ from opentelemetry.sdk.extension.profiling.version import __version__
28
+ from opentelemetry.sdk.resources import Resource
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+ _DEFAULT_AGENT_BASE_URL = "http://127.0.0.1:9529"
33
+ _DEFAULT_AGENT_PATH = "profiling/v1/input"
34
+
35
+
36
+ class PPROFHTTPExporter:
37
+ def __init__(
38
+ self,
39
+ *,
40
+ upload_url: str | None = None,
41
+ headers: Optional[Dict[str, str]] = None,
42
+ timeout: float = 10.0,
43
+ session: Optional[requests.Session] = None,
44
+ pprof_path: str | None = None,
45
+ max_files: int | None = None,
46
+ service: str | None = None,
47
+ env_name: str | None = None,
48
+ version: str | None = None,
49
+ ) -> None:
50
+ self._url = upload_url or _resolve_upload_url()
51
+ self._headers = _resolve_headers(headers)
52
+ self._timeout = timeout
53
+ self._session = session or requests.Session()
54
+ self._pprof_path = (
55
+ pprof_path
56
+ if pprof_path is not None
57
+ else environ.get(OTEL_PROFILING_PPROF_PATH)
58
+ )
59
+ self._max_files = (
60
+ max_files
61
+ if max_files is not None
62
+ else int(environ.get(OTEL_PROFILING_PPROF_MAX_FILES, "3"))
63
+ )
64
+ if self._max_files < 1:
65
+ raise ValueError("max_files must be >= 1")
66
+ self._written_paths: deque[str] = deque()
67
+ self._service = service
68
+ self._env = env_name
69
+ self._version = version
70
+ self._runtime_id = uuid4().hex
71
+ self._host = platform.node()
72
+ self._runtime = platform.python_implementation()
73
+ self._runtime_version = platform.python_version()
74
+ self._pid = os.getpid()
75
+ self._counter = count()
76
+
77
+ def export(
78
+ self,
79
+ payload: bytes,
80
+ *,
81
+ resource: Resource | None = None,
82
+ start_time_unix_nano: int | None = None,
83
+ end_time_unix_nano: int | None = None,
84
+ ) -> ProfileExportResult:
85
+ try:
86
+ filename = self._write_payload(payload)
87
+ except OSError as exc:
88
+ # Local copies are diagnostic output, not a prerequisite for
89
+ # upload. Read-only/full disks must not discard a valid profile.
90
+ _logger.warning("Cannot save local pprof payload: %s", exc)
91
+ filename = None
92
+ try:
93
+ request_headers = dict(self._headers)
94
+ content_type, body = self._build_request(
95
+ payload=payload,
96
+ resource=resource,
97
+ start_time_unix_nano=start_time_unix_nano,
98
+ end_time_unix_nano=end_time_unix_nano,
99
+ )
100
+ request_headers["Content-Type"] = content_type
101
+ response = self._session.post(
102
+ url=self._url,
103
+ data=body,
104
+ headers=request_headers,
105
+ timeout=self._timeout,
106
+ )
107
+ except Exception as exc:
108
+ _logger.error("pprof HTTP export failed: %s", exc)
109
+ return ProfileExportResult.FAILURE
110
+
111
+ if response.status_code not in (200, 202):
112
+ _logger.error(
113
+ "pprof HTTP export failed status %s: %s",
114
+ response.status_code,
115
+ response.text,
116
+ )
117
+ return ProfileExportResult.FAILURE
118
+
119
+ _logger.debug(
120
+ "pprof payload saved to %s and posted to %s",
121
+ filename,
122
+ self._url,
123
+ )
124
+ return ProfileExportResult.SUCCESS
125
+
126
+ def _build_request(
127
+ self,
128
+ *,
129
+ payload: bytes,
130
+ resource: Resource | None,
131
+ start_time_unix_nano: int | None,
132
+ end_time_unix_nano: int | None,
133
+ ) -> tuple[str, bytes]:
134
+ service = self._resolve_service(resource)
135
+ event = {
136
+ "version": "4",
137
+ "family": "python",
138
+ "attachments": ["auto.pprof"],
139
+ "tags_profiler": self._format_tags(service, resource),
140
+ "start": _format_timestamp(
141
+ start_time_unix_nano
142
+ or datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
143
+ * 1_000_000_000
144
+ ),
145
+ "end": _format_timestamp(
146
+ end_time_unix_nano
147
+ or start_time_unix_nano
148
+ or datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
149
+ * 1_000_000_000
150
+ ),
151
+ }
152
+ files = [
153
+ {
154
+ "name": "auto",
155
+ "filename": "auto.pprof",
156
+ "content_type": "application/octet-stream",
157
+ "data": payload,
158
+ }
159
+ ]
160
+ return _encode_multipart_formdata(event=event, files=files)
161
+
162
+ def _format_tags(self, service: str, resource: Resource | None) -> str:
163
+ tags = {
164
+ "service": service,
165
+ "runtime-id": self._runtime_id,
166
+ "host": self._host,
167
+ "language": "python",
168
+ "runtime": self._runtime,
169
+ "runtime_version": self._runtime_version,
170
+ "profiler_version": __version__,
171
+ }
172
+ env_name = self._resolve_env(resource)
173
+ if env_name:
174
+ tags["env"] = env_name
175
+ version = self._resolve_version(resource)
176
+ if version:
177
+ tags["version"] = version
178
+ return ",".join(f"{key}:{value}" for key, value in tags.items())
179
+
180
+ def _resolve_service(self, resource: Resource | None) -> str:
181
+ return (
182
+ _resource_attribute(resource, "service.name")
183
+ or self._service
184
+ or "unknown_service"
185
+ )
186
+
187
+ def _resolve_env(self, resource: Resource | None) -> str | None:
188
+ return (
189
+ _resource_attribute(
190
+ resource,
191
+ "deployment.environment.name",
192
+ "deployment.environment",
193
+ )
194
+ or self._env
195
+ )
196
+
197
+ def _resolve_version(self, resource: Resource | None) -> str | None:
198
+ return (
199
+ _resource_attribute(resource, "service.version") or self._version
200
+ )
201
+
202
+ def _write_payload(self, payload: bytes) -> str | None:
203
+ if not self._pprof_path:
204
+ return None
205
+ filename = (
206
+ f"{self._pprof_path}.{self._pid}.{next(self._counter)}.pprof"
207
+ )
208
+ os.makedirs(os.path.dirname(filename) or ".", exist_ok=True)
209
+ with open(filename, "wb") as handle:
210
+ handle.write(payload)
211
+ self._written_paths.append(filename)
212
+ while len(self._written_paths) > self._max_files:
213
+ previous = self._written_paths.popleft()
214
+ try:
215
+ os.remove(previous)
216
+ except OSError as exc:
217
+ _logger.warning("Cannot remove local pprof payload: %s", exc)
218
+ return filename
219
+
220
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
221
+ del timeout_millis
222
+ return True
223
+
224
+ def shutdown(self) -> None:
225
+ self._session.close()
226
+
227
+
228
+ def _resolve_headers(
229
+ headers: Optional[Dict[str, str]],
230
+ ) -> Dict[str, str]:
231
+ resolved = _parse_headers(environ.get(OTEL_PROFILING_PPROF_HEADERS, ""))
232
+ if headers is not None:
233
+ resolved.update(headers)
234
+ return resolved
235
+
236
+
237
+ def _resolve_upload_url() -> str:
238
+ explicit = environ.get(OTEL_PROFILING_PPROF_UPLOAD_URL) or environ.get(
239
+ OTEL_PROFILING_ENDPOINT
240
+ )
241
+ if explicit:
242
+ return explicit
243
+ return f"{_DEFAULT_AGENT_BASE_URL}/{_DEFAULT_AGENT_PATH}"
244
+
245
+
246
+ def _parse_headers(value: str) -> Dict[str, str]:
247
+ headers = {}
248
+ for header in value.split(","):
249
+ if ":" not in header:
250
+ continue
251
+ key, header_value = header.split(":", 1)
252
+ key = key.strip()
253
+ header_value = header_value.strip()
254
+ if key:
255
+ headers[key] = header_value
256
+ return headers
257
+
258
+
259
+ def _resource_attribute(resource: Resource | None, *keys: str) -> str | None:
260
+ if resource is None:
261
+ return None
262
+ for key in keys:
263
+ value = resource.attributes.get(key)
264
+ if isinstance(value, str) and value:
265
+ return value
266
+ return None
267
+
268
+
269
+ def _format_timestamp(timestamp_unix_nano: float | int) -> str:
270
+ timestamp = datetime.datetime.fromtimestamp(
271
+ float(timestamp_unix_nano) / 1e9,
272
+ tz=datetime.timezone.utc,
273
+ )
274
+ return timestamp.replace(microsecond=0).isoformat()[0:-6] + "Z"
275
+
276
+
277
+ def _encode_multipart_formdata(
278
+ *,
279
+ event: dict[str, str | list[str]],
280
+ files: list[dict[str, str | bytes]],
281
+ ) -> tuple[str, bytes]:
282
+ boundary = binascii.hexlify(os.urandom(16)).decode("ascii")
283
+ boundary_bytes = boundary.encode("ascii")
284
+ body = (
285
+ (b"--%s\r\n" % boundary_bytes)
286
+ + b'Content-Disposition: form-data; name="event"; filename="event.json"\r\n'
287
+ + b"Content-Type: application/json\r\n\r\n"
288
+ + _encode_json(event)
289
+ + b"\r\n"
290
+ + b"".join(
291
+ (b"--%s\r\n" % boundary_bytes)
292
+ + (
293
+ b'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
294
+ % (
295
+ str(item["name"]).encode("utf-8"),
296
+ str(item["filename"]).encode("utf-8"),
297
+ )
298
+ )
299
+ + (
300
+ b"Content-Type: %s\r\n\r\n"
301
+ % str(item["content_type"]).encode("utf-8")
302
+ )
303
+ + bytes(item["data"])
304
+ + b"\r\n"
305
+ for item in files
306
+ )
307
+ + b"--%s--\r\n" % boundary_bytes
308
+ )
309
+ return f"multipart/form-data; boundary={boundary}", body
310
+
311
+
312
+ def _encode_json(payload: dict[str, str | list[str]]) -> bytes:
313
+ return json.dumps(payload).encode("utf-8")
@@ -65,7 +65,7 @@ from opentelemetry.sdk.extension.profiling.environment_variables import (
65
65
  OTEL_PROFILING_SAMPLE_INTERVAL,
66
66
  )
67
67
  from opentelemetry.sdk.extension.profiling.export import (
68
- create_otlp_profile_exporter,
68
+ CompatiblePPROFExporter,
69
69
  )
70
70
  from opentelemetry.sdk.extension.profiling.export.file import (
71
71
  FileProfileExporter,
@@ -74,6 +74,7 @@ from opentelemetry.sdk.extension.profiling.model.builder import (
74
74
  ProfilesRequestBuilder,
75
75
  )
76
76
  from opentelemetry.sdk.extension.profiling.model.pprof_builder import (
77
+ CompatiblePprofProfileBuilder,
77
78
  PprofProfileBuilder,
78
79
  )
79
80
  from opentelemetry.sdk.extension.profiling.scheduler import ProfileScheduler
@@ -432,6 +433,18 @@ class Profiler:
432
433
  resource=resource,
433
434
  sample_period_ns=int(self._sample_interval * 1_000_000_000),
434
435
  )
436
+ if isinstance(self._exporter, CompatiblePPROFExporter):
437
+ self._exporter.export(
438
+ payload,
439
+ resource=resource,
440
+ start_time_unix_nano=min(
441
+ sample.timestamp_unix_nano for sample in batch
442
+ ),
443
+ end_time_unix_nano=max(
444
+ sample.timestamp_unix_nano for sample in batch
445
+ ),
446
+ )
447
+ return
435
448
  self._exporter.export(payload)
436
449
 
437
450
  def force_flush(self) -> None:
@@ -447,20 +460,22 @@ class Profiler:
447
460
  return Resource.create()
448
461
 
449
462
  def _create_builder(self):
463
+ if isinstance(self._exporter, CompatiblePPROFExporter):
464
+ return CompatiblePprofProfileBuilder()
450
465
  if isinstance(self._exporter, FileProfileExporter):
451
466
  return PprofProfileBuilder()
452
467
  return ProfilesRequestBuilder()
453
468
 
454
469
  @staticmethod
455
470
  def _create_exporter_from_env():
456
- exporter_name = environ.get(OTEL_PROFILING_EXPORTER, "otlp").strip()
471
+ exporter_name = environ.get(OTEL_PROFILING_EXPORTER, "http").strip()
457
472
  if exporter_name == "file":
458
473
  return FileProfileExporter()
459
- if exporter_name != "otlp":
474
+ if exporter_name not in ("http", "datakit", "pprof_http"):
460
475
  raise ValueError(
461
476
  f"Unsupported profiling exporter '{exporter_name}'"
462
477
  )
463
- return create_otlp_profile_exporter()
478
+ return CompatiblePPROFExporter()
464
479
 
465
480
  def _start_collectors(self) -> list[Collector]:
466
481
  active_collectors = []
@@ -1,2 +1,2 @@
1
1
  # Generated from beacon/version.properties by beacon/scripts/check-version.py.
2
- __version__ = "1.2.6"
2
+ __version__ = "1.2.7"
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import json
5
+ from email import policy
6
+ from email.parser import BytesParser
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from threading import Event, Thread
9
+ from time import sleep
10
+
11
+ from opentelemetry.sdk.extension.profiling.export import pprof_pb2
12
+ from opentelemetry.sdk.extension.profiling.runtime import Profiler
13
+ from opentelemetry.sdk.resources import Resource
14
+
15
+
16
+ def test_http_environment_name_keeps_real_multipart_pprof_upload(monkeypatch):
17
+ received = {}
18
+ ready = Event()
19
+
20
+ class Handler(BaseHTTPRequestHandler):
21
+ def do_POST(self) -> None:
22
+ body = self.rfile.read(int(self.headers["Content-Length"]))
23
+ message = BytesParser(policy=policy.default).parsebytes(
24
+ f"Content-Type: {self.headers['Content-Type']}\r\n\r\n".encode()
25
+ + body
26
+ )
27
+ received["path"] = self.path
28
+ received["parts"] = {
29
+ part.get_filename(): part.get_payload(decode=True)
30
+ for part in message.iter_parts()
31
+ }
32
+ self.send_response(200)
33
+ self.end_headers()
34
+ ready.set()
35
+
36
+ def log_message(self, format: str, *args) -> None: # noqa: A002
37
+ return None
38
+
39
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
40
+ worker = Thread(target=server.serve_forever, daemon=True)
41
+ worker.start()
42
+ monkeypatch.setenv("OTEL_PROFILING_EXPORTER", "http")
43
+ monkeypatch.delenv("OTEL_PROFILING_PPROF_UPLOAD_URL", raising=False)
44
+ monkeypatch.setenv(
45
+ "OTEL_PROFILING_ENDPOINT",
46
+ f"http://127.0.0.1:{server.server_port}/profiling/v1/input",
47
+ )
48
+ profiler = Profiler(
49
+ sample_interval=0.005,
50
+ export_interval=3600,
51
+ exception_enabled=False,
52
+ lock_enabled=False,
53
+ memory_enabled=False,
54
+ resource=Resource.create({"service.name": "multipart-regression"}),
55
+ )
56
+ try:
57
+ profiler.start()
58
+ sleep(0.1)
59
+ profiler.stop()
60
+ assert ready.wait(3)
61
+ assert received["path"] == "/profiling/v1/input"
62
+ event = json.loads(received["parts"]["event.json"])
63
+ assert event["family"] == "python"
64
+ assert event["attachments"] == ["auto.pprof"]
65
+ assert "service:multipart-regression" in event["tags_profiler"]
66
+ profile = pprof_pb2.Profile.FromString(
67
+ gzip.decompress(received["parts"]["auto.pprof"])
68
+ )
69
+ assert profile.sample
70
+ assert profile.function
71
+ finally:
72
+ profiler.stop()
73
+ server.shutdown()
74
+ server.server_close()
75
+ worker.join(3)
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ from unittest.mock import Mock
5
+
6
+ import pytest
7
+
8
+ from opentelemetry.sdk.extension.profiling.export.pprof_http import (
9
+ PPROFHTTPExporter,
10
+ )
11
+ from opentelemetry.sdk.resources import Resource
12
+
13
+
14
+ def test_pprof_http_exporter_posts_multipart_payload(monkeypatch, tmp_path):
15
+ session = Mock()
16
+ session.post.return_value = Mock(status_code=202, text="ok")
17
+ monkeypatch.setenv(
18
+ "OTEL_PROFILING_PPROF_HEADERS",
19
+ "X-API-Key:api-key",
20
+ )
21
+ monkeypatch.setenv(
22
+ "OTEL_PROFILING_PPROF_UPLOAD_URL",
23
+ "http://agent:8126/profiling/v1/input",
24
+ )
25
+ exporter = PPROFHTTPExporter(
26
+ session=session,
27
+ pprof_path=str(tmp_path / "prof"),
28
+ )
29
+ payload = b"foo"
30
+ resource = Resource.create(
31
+ {
32
+ "service.name": "svc",
33
+ "service.version": "1.2.3",
34
+ "deployment.environment.name": "prod",
35
+ }
36
+ )
37
+
38
+ result = exporter.export(
39
+ payload,
40
+ resource=resource,
41
+ start_time_unix_nano=1_700_000_000_000_000_000,
42
+ end_time_unix_nano=1_700_000_060_000_000_000,
43
+ )
44
+
45
+ assert result.name == "SUCCESS"
46
+ session.post.assert_called_once()
47
+ kwargs = session.post.call_args.kwargs
48
+ assert kwargs["url"] == "http://agent:8126/profiling/v1/input"
49
+ assert kwargs["headers"]["X-API-Key"] == "api-key"
50
+ assert kwargs["headers"]["Content-Type"].startswith(
51
+ "multipart/form-data; boundary="
52
+ )
53
+ body = kwargs["data"]
54
+ assert b'filename="event.json"' in body
55
+ assert b'filename="auto.pprof"' in body
56
+ assert b'"family": "python"' in body
57
+ assert b"service:svc" in body
58
+ assert b"env:prod" in body
59
+ assert b"version:1.2.3" in body
60
+ assert payload in body
61
+ files = list(tmp_path.glob("prof*.pprof"))
62
+ assert files
63
+
64
+
65
+ def test_pprof_http_exporter_handles_failure(monkeypatch, tmp_path):
66
+ session = Mock()
67
+ session.post.side_effect = Exception("boom")
68
+ exporter = PPROFHTTPExporter(
69
+ session=session,
70
+ pprof_path=str(tmp_path / "prof"),
71
+ )
72
+
73
+ result = exporter.export(b"bar")
74
+
75
+ assert result.name == "FAILURE"
76
+ files = list(tmp_path.glob("prof*.pprof"))
77
+ assert files
78
+
79
+
80
+ def test_http_upload_does_not_require_a_writable_directory(
81
+ monkeypatch, tmp_path
82
+ ) -> None:
83
+ monkeypatch.chdir(tmp_path)
84
+ monkeypatch.delenv("OTEL_PROFILING_PPROF_PATH", raising=False)
85
+ session = Mock()
86
+ session.post.return_value = Mock(status_code=202)
87
+ exporter = PPROFHTTPExporter(session=session)
88
+ assert exporter.export(b"payload").name == "SUCCESS"
89
+ assert list(tmp_path.iterdir()) == []
90
+ session.post.assert_called_once()
91
+
92
+
93
+ @pytest.mark.parametrize("error_number", [errno.EACCES, errno.ENOSPC])
94
+ def test_local_write_failure_still_uploads_and_recovers(
95
+ monkeypatch, tmp_path, error_number: int, caplog
96
+ ) -> None:
97
+ session = Mock()
98
+ session.post.return_value = Mock(status_code=202)
99
+ exporter = PPROFHTTPExporter(
100
+ session=session, pprof_path=str(tmp_path / "profile")
101
+ )
102
+ write = exporter._write_payload
103
+
104
+ def fail(payload: bytes) -> None:
105
+ raise OSError(error_number, "write unavailable")
106
+
107
+ monkeypatch.setattr(exporter, "_write_payload", fail)
108
+ assert exporter.export(b"first").name == "SUCCESS"
109
+ assert "Cannot save local pprof" in caplog.text
110
+ monkeypatch.setattr(exporter, "_write_payload", write)
111
+ assert exporter.export(b"second").name == "SUCCESS"
112
+ assert session.post.call_count == 2
113
+ assert next(tmp_path.glob("*.pprof")).read_bytes() == b"second"
114
+
115
+
116
+ def test_http_local_copies_have_bounded_instance_retention(
117
+ monkeypatch, tmp_path
118
+ ) -> None:
119
+ session = Mock()
120
+ session.post.return_value = Mock(status_code=202)
121
+ monkeypatch.setenv("OTEL_PROFILING_PPROF_MAX_FILES", "2")
122
+ exporter = PPROFHTTPExporter(
123
+ session=session, pprof_path=str(tmp_path / "profile")
124
+ )
125
+ other = tmp_path / "profile.other-process.pprof"
126
+ other.write_bytes(b"keep")
127
+ for index in range(5):
128
+ assert exporter.export(str(index).encode()).name == "SUCCESS"
129
+ copies = [path for path in tmp_path.glob("*.pprof") if path != other]
130
+ assert {path.read_bytes() for path in copies} == {b"3", b"4"}
131
+ assert other.read_bytes() == b"keep"
132
+
133
+
134
+ def test_explicit_empty_path_disables_environment_local_copies(
135
+ monkeypatch, tmp_path
136
+ ) -> None:
137
+ monkeypatch.setenv("OTEL_PROFILING_PPROF_PATH", str(tmp_path / "profile"))
138
+ session = Mock()
139
+ session.post.return_value = Mock(status_code=202)
140
+ exporter = PPROFHTTPExporter(session=session, pprof_path="")
141
+ assert exporter.export(b"payload").name == "SUCCESS"
142
+ assert list(tmp_path.iterdir()) == []
143
+
144
+
145
+ def test_build_failure_returns_failure_and_next_export_recovers(
146
+ monkeypatch,
147
+ ) -> None:
148
+ session = Mock()
149
+ session.post.return_value = Mock(status_code=202)
150
+ exporter = PPROFHTTPExporter(session=session, pprof_path="")
151
+ build = exporter._build_request
152
+ monkeypatch.setattr(
153
+ exporter, "_build_request", Mock(side_effect=ValueError("bad build"))
154
+ )
155
+ assert exporter.export(b"first").name == "FAILURE"
156
+ session.post.assert_not_called()
157
+ monkeypatch.setattr(exporter, "_build_request", build)
158
+ assert exporter.export(b"second").name == "SUCCESS"
@@ -5,23 +5,33 @@ import pytest
5
5
  from opentelemetry.sdk.extension.profiling.export.file import (
6
6
  FileProfileExporter,
7
7
  )
8
+ from opentelemetry.sdk.extension.profiling.export.pprof_http import (
9
+ PPROFHTTPExporter,
10
+ )
8
11
  from opentelemetry.sdk.extension.profiling.model.pprof_builder import (
12
+ CompatiblePprofProfileBuilder,
9
13
  PprofProfileBuilder,
10
14
  )
11
15
  from opentelemetry.sdk.extension.profiling.runtime import Profiler
12
16
 
13
17
 
14
- def test_runtime_defaults_to_otlp(monkeypatch):
15
- sentinel = object()
16
- monkeypatch.delenv("OTEL_PROFILING_EXPORTER", raising=False)
18
+ @pytest.mark.parametrize("name", [None, "http", "datakit", "pprof_http"])
19
+ def test_runtime_keeps_compatible_upload_for_http_name(monkeypatch, name):
20
+ monkeypatch.delenv("OTEL_PROFILING_PPROF_UPLOAD_URL", raising=False)
21
+ if name is None:
22
+ monkeypatch.delenv("OTEL_PROFILING_EXPORTER", raising=False)
23
+ else:
24
+ monkeypatch.setenv("OTEL_PROFILING_EXPORTER", name)
17
25
  monkeypatch.setenv(
18
- "OTEL_PROFILING_PPROF_UPLOAD_URL", "invalid old endpoint"
26
+ "OTEL_PROFILING_ENDPOINT", "http://127.0.0.1:9529/profiling/v1/input"
19
27
  )
20
- monkeypatch.setattr(
21
- "opentelemetry.sdk.extension.profiling.runtime.create_otlp_profile_exporter",
22
- lambda: sentinel,
28
+ exporter = Profiler._create_exporter_from_env()
29
+ assert isinstance(exporter, PPROFHTTPExporter)
30
+ assert exporter._url == "http://127.0.0.1:9529/profiling/v1/input"
31
+ assert isinstance(
32
+ Profiler(exporter=exporter)._builder, CompatiblePprofProfileBuilder
23
33
  )
24
- assert Profiler._create_exporter_from_env() is sentinel
34
+ exporter.shutdown()
25
35
 
26
36
 
27
37
  def test_file_ignores_all_endpoint_settings(monkeypatch, tmp_path):
@@ -38,8 +48,8 @@ def test_file_ignores_all_endpoint_settings(monkeypatch, tmp_path):
38
48
  assert next(tmp_path.glob("*.pprof")).read_bytes() == b"profile"
39
49
 
40
50
 
41
- @pytest.mark.parametrize("name", ["datakit", "pprof", "pprof_http", "none"])
42
- def test_removed_exporter_names_rejected(monkeypatch, name):
51
+ @pytest.mark.parametrize("name", ["unknown", "pprof", "none", "otlp"])
52
+ def test_unsupported_exporter_names_rejected(monkeypatch, name):
43
53
  monkeypatch.setenv("OTEL_PROFILING_EXPORTER", name)
44
54
  with pytest.raises(ValueError, match="Unsupported profiling exporter"):
45
55
  Profiler._create_exporter_from_env()