scout-apm 3.0.2__cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl → 3.3.0__cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Sign up to get free protection for your applications and to get access to all the features.
scout_apm/api/__init__.py CHANGED
@@ -97,6 +97,7 @@ class Transaction(AsyncDecoratorMixin, ContextDecorator):
97
97
  operation = text(kind) + "/" + text(name)
98
98
 
99
99
  tracked_request = TrackedRequest.instance()
100
+ tracked_request.operation = operation
100
101
  tracked_request.is_real_request = True
101
102
  span = tracked_request.start_span(
102
103
  operation=operation, should_capture_backtrace=False
@@ -40,6 +40,7 @@ class ScoutMiddleware:
40
40
  endpoint.__module__,
41
41
  endpoint.__qualname__,
42
42
  )
43
+ tracked_request.operation = controller_span.operation
43
44
  else:
44
45
  # Mark the request as not real
45
46
  tracked_request.is_real_request = False
@@ -90,11 +91,11 @@ def install_background_instrumentation():
90
91
  tracked_request = TrackedRequest.instance()
91
92
  tracked_request.is_real_request = True
92
93
 
93
- with tracked_request.span(
94
- operation="Job/{}.{}".format(
95
- instance.func.__module__, instance.func.__qualname__
96
- )
97
- ):
94
+ operation = "Job/{}.{}".format(
95
+ instance.func.__module__, instance.func.__qualname__
96
+ )
97
+ tracked_request.operation = operation
98
+ with tracked_request.span(operation=operation):
98
99
  return await wrapped(*args, **kwargs)
99
100
 
100
101
  BackgroundTask.__call__ = wrapped_background_call(BackgroundTask.__call__)
scout_apm/bottle.py CHANGED
@@ -5,12 +5,9 @@ from bottle import request, response
5
5
 
6
6
  import scout_apm.core
7
7
  from scout_apm.core.config import scout_config
8
+ from scout_apm.core.queue_time import track_request_queue_time
8
9
  from scout_apm.core.tracked_request import TrackedRequest
9
- from scout_apm.core.web_requests import (
10
- create_filtered_path,
11
- ignore_path,
12
- track_request_queue_time,
13
- )
10
+ from scout_apm.core.web_requests import create_filtered_path, ignore_path
14
11
 
15
12
 
16
13
  class ScoutPlugin(object):
@@ -74,10 +71,10 @@ def wrap_callback(wrapped, instance, args, kwargs):
74
71
  "x-request-start", ""
75
72
  )
76
73
  track_request_queue_time(queue_time, tracked_request)
74
+ operation = "Controller{}".format(controller_name)
77
75
 
78
- with tracked_request.span(
79
- operation="Controller{}".format(controller_name), should_capture_backtrace=False
80
- ):
76
+ with tracked_request.span(operation=operation):
77
+ tracked_request.operation = operation
81
78
  try:
82
79
  value = wrapped(*args, **kwargs)
83
80
  except Exception:
scout_apm/celery.py CHANGED
@@ -5,6 +5,8 @@ import logging
5
5
 
6
6
  from celery.signals import before_task_publish, task_failure, task_postrun, task_prerun
7
7
 
8
+ from scout_apm.core.queue_time import track_job_queue_time
9
+
8
10
  try:
9
11
  import django
10
12
  from django.views.debug import SafeExceptionReporterFilter
@@ -27,22 +29,17 @@ logger = logging.getLogger(__name__)
27
29
 
28
30
  def before_task_publish_callback(headers=None, properties=None, **kwargs):
29
31
  if "scout_task_start" not in headers:
30
- headers["scout_task_start"] = datetime_to_timestamp(dt.datetime.utcnow())
32
+ headers["scout_task_start"] = datetime_to_timestamp(
33
+ dt.datetime.now(dt.timezone.utc)
34
+ )
31
35
 
32
36
 
33
37
  def task_prerun_callback(task=None, **kwargs):
34
38
  tracked_request = TrackedRequest.instance()
35
39
  tracked_request.is_real_request = True
36
40
 
37
- start = getattr(task.request, "scout_task_start", None)
38
- if start is not None:
39
- now = datetime_to_timestamp(dt.datetime.utcnow())
40
- try:
41
- queue_time = now - start
42
- except TypeError:
43
- pass
44
- else:
45
- tracked_request.tag("queue_time", queue_time)
41
+ start_time_header = getattr(task.request, "scout_task_start", None)
42
+ track_job_queue_time(start_time_header, tracked_request)
46
43
 
47
44
  task_id = getattr(task.request, "id", None)
48
45
  if task_id:
@@ -59,7 +56,9 @@ def task_prerun_callback(task=None, **kwargs):
59
56
  tracked_request.tag("routing_key", delivery_info.get("routing_key", "unknown"))
60
57
  tracked_request.tag("queue", delivery_info.get("queue", "unknown"))
61
58
 
62
- tracked_request.start_span(operation=("Job/" + task.name))
59
+ operation = "Job/" + task.name
60
+ tracked_request.start_span(operation=operation)
61
+ tracked_request.operation = operation
63
62
 
64
63
 
65
64
  def task_postrun_callback(task=None, **kwargs):
@@ -1,5 +1,6 @@
1
1
  # coding=utf-8
2
2
 
3
+ import datetime as dt
3
4
  import logging
4
5
  import re
5
6
 
@@ -10,6 +11,18 @@ logger = logging.getLogger(__name__)
10
11
  key_regex = re.compile(r"^[a-zA-Z0-9]{20}$")
11
12
 
12
13
 
14
+ def format_dt_for_core_agent(event_time: dt.datetime) -> str:
15
+ """
16
+ Returns expected format for Core Agent compatibility.
17
+ Coerce any tz-aware datetime to UTC just in case.
18
+ """
19
+ # if we somehow got a naive datetime, convert it to UTC
20
+ if event_time.tzinfo is None:
21
+ logger.warning("Naive datetime passed to format_dt_for_core_agent")
22
+ event_time = event_time.astimezone(dt.timezone.utc)
23
+ return event_time.isoformat()
24
+
25
+
13
26
  class Register(object):
14
27
  __slots__ = ("app", "key", "hostname")
15
28
 
@@ -49,7 +62,7 @@ class StartSpan(object):
49
62
  def message(self):
50
63
  return {
51
64
  "StartSpan": {
52
- "timestamp": self.timestamp.isoformat() + "Z",
65
+ "timestamp": format_dt_for_core_agent(self.timestamp),
53
66
  "request_id": self.request_id,
54
67
  "span_id": self.span_id,
55
68
  "parent_id": self.parent,
@@ -69,7 +82,7 @@ class StopSpan(object):
69
82
  def message(self):
70
83
  return {
71
84
  "StopSpan": {
72
- "timestamp": self.timestamp.isoformat() + "Z",
85
+ "timestamp": format_dt_for_core_agent(self.timestamp),
73
86
  "request_id": self.request_id,
74
87
  "span_id": self.span_id,
75
88
  }
@@ -86,7 +99,7 @@ class StartRequest(object):
86
99
  def message(self):
87
100
  return {
88
101
  "StartRequest": {
89
- "timestamp": self.timestamp.isoformat() + "Z",
102
+ "timestamp": format_dt_for_core_agent(self.timestamp),
90
103
  "request_id": self.request_id,
91
104
  }
92
105
  }
@@ -102,7 +115,7 @@ class FinishRequest(object):
102
115
  def message(self):
103
116
  return {
104
117
  "FinishRequest": {
105
- "timestamp": self.timestamp.isoformat() + "Z",
118
+ "timestamp": format_dt_for_core_agent(self.timestamp),
106
119
  "request_id": self.request_id,
107
120
  }
108
121
  }
@@ -121,7 +134,7 @@ class TagSpan(object):
121
134
  def message(self):
122
135
  return {
123
136
  "TagSpan": {
124
- "timestamp": self.timestamp.isoformat() + "Z",
137
+ "timestamp": format_dt_for_core_agent(self.timestamp),
125
138
  "request_id": self.request_id,
126
139
  "span_id": self.span_id,
127
140
  "tag": self.tag,
@@ -142,7 +155,7 @@ class TagRequest(object):
142
155
  def message(self):
143
156
  return {
144
157
  "TagRequest": {
145
- "timestamp": self.timestamp.isoformat() + "Z",
158
+ "timestamp": format_dt_for_core_agent(self.timestamp),
146
159
  "request_id": self.request_id,
147
160
  "tag": self.tag,
148
161
  "value": self.value,
@@ -162,7 +175,7 @@ class ApplicationEvent(object):
162
175
  def message(self):
163
176
  return {
164
177
  "ApplicationEvent": {
165
- "timestamp": self.timestamp.isoformat() + "Z",
178
+ "timestamp": format_dt_for_core_agent(self.timestamp),
166
179
  "event_type": self.event_type,
167
180
  "event_value": self.event_value,
168
181
  "source": self.source,
@@ -5,7 +5,6 @@ import hashlib
5
5
  import json
6
6
  import logging
7
7
  import os
8
- import signal
9
8
  import subprocess
10
9
  import tarfile
11
10
  import time
@@ -17,6 +16,8 @@ from scout_apm.core.config import scout_config
17
16
 
18
17
  logger = logging.getLogger(__name__)
19
18
 
19
+ CA_ALREADY_RUNNING_EXIT_CODE = 3
20
+
20
21
 
21
22
  class CoreAgentManager(object):
22
23
  def __init__(self):
@@ -73,8 +74,10 @@ class CoreAgentManager(object):
73
74
  stdout=devnull,
74
75
  )
75
76
  except subprocess.CalledProcessError as err:
76
- if err.returncode in [signal.SIGTERM, signal.SIGQUIT]:
77
- logger.debug("Core agent returned signal: {}".format(err.returncode))
77
+ if err.returncode == CA_ALREADY_RUNNING_EXIT_CODE:
78
+ # Other processes may have already started the core agent.
79
+ logger.debug("Core agent already running.")
80
+ return True
78
81
  else:
79
82
  logger.exception("CalledProcessError running Core Agent")
80
83
  return False
scout_apm/core/config.py CHANGED
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import warnings
7
+ from typing import Any, Dict, List, Optional, Union
7
8
 
8
9
  from scout_apm.core import platform_detection
9
10
 
@@ -30,13 +31,13 @@ class ScoutConfig(object):
30
31
  Null(),
31
32
  ]
32
33
 
33
- def value(self, key):
34
+ def value(self, key: str) -> Any:
34
35
  value = self.locate_layer_for_key(key).value(key)
35
36
  if key in CONVERSIONS:
36
37
  return CONVERSIONS[key](value)
37
38
  return value
38
39
 
39
- def locate_layer_for_key(self, key):
40
+ def locate_layer_for_key(self, key: str) -> Any:
40
41
  for layer in self.layers:
41
42
  if layer.has_config(key):
42
43
  return layer
@@ -44,7 +45,7 @@ class ScoutConfig(object):
44
45
  # Should be unreachable because Null returns None for all keys.
45
46
  raise ValueError("key {!r} not found in any layer".format(key))
46
47
 
47
- def log(self):
48
+ def log(self) -> None:
48
49
  logger.debug("Configuration Loaded:")
49
50
  for key in self.known_keys:
50
51
  if key in self.secret_keys:
@@ -76,13 +77,20 @@ class ScoutConfig(object):
76
77
  "framework",
77
78
  "framework_version",
78
79
  "hostname",
79
- "ignore",
80
+ "ignore", # Deprecated in favor of ignore_endpoints
81
+ "ignore_endpoints",
82
+ "ignore_jobs",
80
83
  "key",
81
84
  "log_level",
82
85
  "log_payload_content",
83
86
  "monitor",
84
87
  "name",
85
88
  "revision_sha",
89
+ "sample_rate",
90
+ "endpoint_sample_rate",
91
+ "sample_endpoints",
92
+ "sample_jobs",
93
+ "job_sample_rate",
86
94
  "scm_subdirectory",
87
95
  "shutdown_message_enabled",
88
96
  "shutdown_timeout_seconds",
@@ -90,7 +98,7 @@ class ScoutConfig(object):
90
98
 
91
99
  secret_keys = {"key"}
92
100
 
93
- def core_agent_permissions(self):
101
+ def core_agent_permissions(self) -> int:
94
102
  try:
95
103
  return int(str(self.value("core_agent_permissions")), 8)
96
104
  except ValueError:
@@ -100,7 +108,7 @@ class ScoutConfig(object):
100
108
  return 0o700
101
109
 
102
110
  @classmethod
103
- def set(cls, **kwargs):
111
+ def set(cls, **kwargs: Any) -> None:
104
112
  """
105
113
  Sets a configuration value for the Scout agent. Values set here will
106
114
  not override values set in ENV.
@@ -109,7 +117,7 @@ class ScoutConfig(object):
109
117
  SCOUT_PYTHON_VALUES[key] = value
110
118
 
111
119
  @classmethod
112
- def unset(cls, *keys):
120
+ def unset(cls, *keys: str) -> None:
113
121
  """
114
122
  Removes a configuration value for the Scout agent.
115
123
  """
@@ -117,7 +125,7 @@ class ScoutConfig(object):
117
125
  SCOUT_PYTHON_VALUES.pop(key, None)
118
126
 
119
127
  @classmethod
120
- def reset_all(cls):
128
+ def reset_all(cls) -> None:
121
129
  """
122
130
  Remove all configuration settings set via `ScoutConfig.set(...)`.
123
131
 
@@ -135,10 +143,10 @@ class Python(object):
135
143
  A configuration overlay that lets other parts of python set values.
136
144
  """
137
145
 
138
- def has_config(self, key):
146
+ def has_config(self, key: str) -> bool:
139
147
  return key in SCOUT_PYTHON_VALUES
140
148
 
141
- def value(self, key):
149
+ def value(self, key: str) -> Any:
142
150
  return SCOUT_PYTHON_VALUES[key]
143
151
 
144
152
 
@@ -151,15 +159,15 @@ class Env(object):
151
159
  environment variable
152
160
  """
153
161
 
154
- def has_config(self, key):
162
+ def has_config(self, key: str) -> bool:
155
163
  env_key = self.modify_key(key)
156
164
  return env_key in os.environ
157
165
 
158
- def value(self, key):
166
+ def value(self, key: str) -> Any:
159
167
  env_key = self.modify_key(key)
160
168
  return os.environ[env_key]
161
169
 
162
- def modify_key(self, key):
170
+ def modify_key(self, key: str) -> str:
163
171
  env_key = ("SCOUT_" + key).upper()
164
172
  return env_key
165
173
 
@@ -169,27 +177,27 @@ class Derived(object):
169
177
  A configuration overlay that calculates from other values.
170
178
  """
171
179
 
172
- def __init__(self, config):
180
+ def __init__(self, config: ScoutConfig):
173
181
  """
174
182
  config argument is the overall ScoutConfig var, so we can lookup the
175
183
  components of the derived info.
176
184
  """
177
185
  self.config = config
178
186
 
179
- def has_config(self, key):
187
+ def has_config(self, key: str) -> bool:
180
188
  return self.lookup_func(key) is not None
181
189
 
182
- def value(self, key):
190
+ def value(self, key: str) -> Any:
183
191
  return self.lookup_func(key)()
184
192
 
185
- def lookup_func(self, key):
193
+ def lookup_func(self, key: str) -> Optional[Any]:
186
194
  """
187
195
  Returns the derive_#{key} function, or None if it isn't defined
188
196
  """
189
197
  func_name = "derive_" + key
190
198
  return getattr(self, func_name, None)
191
199
 
192
- def derive_core_agent_full_name(self):
200
+ def derive_core_agent_full_name(self) -> str:
193
201
  triple = self.config.value("core_agent_triple")
194
202
  if not platform_detection.is_valid_triple(triple):
195
203
  warnings.warn(
@@ -201,7 +209,7 @@ class Derived(object):
201
209
  triple=triple,
202
210
  )
203
211
 
204
- def derive_core_agent_triple(self):
212
+ def derive_core_agent_triple(self) -> str:
205
213
  return platform_detection.get_triple()
206
214
 
207
215
 
@@ -221,9 +229,12 @@ class Defaults(object):
221
229
  "core_agent_log_level": "info",
222
230
  "core_agent_permissions": 700,
223
231
  "core_agent_socket_path": "tcp://127.0.0.1:6590",
224
- "core_agent_version": "v1.4.0", # can be an exact tag name, or 'latest'
232
+ "core_agent_version": "v1.5.0", # can be an exact tag name, or 'latest'
225
233
  "disabled_instruments": [],
226
- "download_url": "https://s3-us-west-1.amazonaws.com/scout-public-downloads/apm_core_agent/release", # noqa: B950
234
+ "download_url": (
235
+ "https://s3-us-west-1.amazonaws.com/scout-public-downloads/"
236
+ "apm_core_agent/release"
237
+ ), # noqa: B950
227
238
  "errors_batch_size": 5,
228
239
  "errors_enabled": True,
229
240
  "errors_ignored_exceptions": (),
@@ -231,26 +242,34 @@ class Defaults(object):
231
242
  "framework": "",
232
243
  "framework_version": "",
233
244
  "hostname": None,
245
+ "ignore": [],
246
+ "ignore_endpoints": [],
247
+ "ignore_jobs": [],
234
248
  "key": "",
235
249
  "log_payload_content": False,
236
250
  "monitor": False,
237
251
  "name": "Python App",
238
252
  "revision_sha": self._git_revision_sha(),
253
+ "sample_rate": 100,
254
+ "sample_endpoints": [],
255
+ "endpoint_sample_rate": None,
256
+ "sample_jobs": [],
257
+ "job_sample_rate": None,
239
258
  "scm_subdirectory": "",
240
259
  "shutdown_message_enabled": True,
241
260
  "shutdown_timeout_seconds": 2.0,
242
261
  "uri_reporting": "filtered_params",
243
262
  }
244
263
 
245
- def _git_revision_sha(self):
264
+ def _git_revision_sha(self) -> str:
246
265
  # N.B. The environment variable SCOUT_REVISION_SHA may also be used,
247
266
  # but that will be picked up by Env
248
267
  return os.environ.get("HEROKU_SLUG_COMMIT", "")
249
268
 
250
- def has_config(self, key):
269
+ def has_config(self, key: str) -> bool:
251
270
  return key in self.defaults
252
271
 
253
- def value(self, key):
272
+ def value(self, key: str) -> Any:
254
273
  return self.defaults[key]
255
274
 
256
275
 
@@ -261,14 +280,18 @@ class Null(object):
261
280
  Used as the last step of the layered configuration.
262
281
  """
263
282
 
264
- def has_config(self, key):
283
+ def has_config(self, key: str) -> bool:
265
284
  return True
266
285
 
267
- def value(self, key):
286
+ def value(self, key: str) -> None:
268
287
  return None
269
288
 
270
289
 
271
- def convert_to_bool(value):
290
+ def _strip_leading_slash(path: str) -> str:
291
+ return path.lstrip(" /").strip()
292
+
293
+
294
+ def convert_to_bool(value: Any) -> bool:
272
295
  if isinstance(value, bool):
273
296
  return value
274
297
  if isinstance(value, str):
@@ -277,14 +300,38 @@ def convert_to_bool(value):
277
300
  return False
278
301
 
279
302
 
280
- def convert_to_float(value):
303
+ def convert_to_float(value: Any) -> float:
281
304
  try:
282
305
  return float(value)
283
306
  except ValueError:
284
307
  return 0.0
285
308
 
286
309
 
287
- def convert_to_list(value):
310
+ def convert_sample_rate(value: Any) -> Optional[int]:
311
+ """
312
+ Converts sample rate to integer, ensuring it's between 0 and 100.
313
+ Allows None as a valid value.
314
+ """
315
+ if value is None:
316
+ return None
317
+ try:
318
+ rate = int(value)
319
+ if not (0 <= rate <= 100):
320
+ logger.warning(
321
+ f"Invalid sample rate {rate}. Must be between 0 and 100. "
322
+ "Defaulting to 100."
323
+ )
324
+ return 100
325
+ return rate
326
+ except (TypeError, ValueError):
327
+ logger.warning(
328
+ f"Invalid sample rate {value}. Must be a number between 0 and 100. "
329
+ "Defaulting to 100."
330
+ )
331
+ return 100
332
+
333
+
334
+ def convert_to_list(value: Any) -> List[Any]:
288
335
  if isinstance(value, list):
289
336
  return value
290
337
  if isinstance(value, tuple):
@@ -296,13 +343,59 @@ def convert_to_list(value):
296
343
  return []
297
344
 
298
345
 
346
+ def convert_ignore_paths(value: Any) -> List[str]:
347
+ """
348
+ Removes leading slashes from paths and returns a list of strings.
349
+ """
350
+ raw_paths = convert_to_list(value)
351
+ return [_strip_leading_slash(path) for path in raw_paths]
352
+
353
+
354
+ def convert_endpoint_sampling(value: Union[str, Dict[str, Any]]) -> Dict[str, int]:
355
+ """
356
+ Converts endpoint sampling configuration from string or dict format
357
+ to a normalized dict.
358
+ Example: '/endpoint:40,/test:0' -> {'/endpoint': 40, '/test': 0}
359
+ """
360
+ if isinstance(value, dict):
361
+ return {_strip_leading_slash(k): int(v) for k, v in value.items()}
362
+ if isinstance(value, str):
363
+ if not value.strip():
364
+ return {}
365
+ result = {}
366
+ pairs = [pair.strip() for pair in value.split(",")]
367
+ for pair in pairs:
368
+ try:
369
+ endpoint, rate = pair.split(":")
370
+ rate_int = int(rate)
371
+ if not (0 <= rate_int <= 100):
372
+ logger.warning(
373
+ f"Invalid sampling rate {rate} for endpoint {endpoint}. "
374
+ "Must be between 0 and 100."
375
+ )
376
+ continue
377
+ result[_strip_leading_slash(endpoint)] = rate_int
378
+ except ValueError:
379
+ logger.warning(f"Invalid sampling configuration: {pair}")
380
+ continue
381
+ return result
382
+ return {}
383
+
384
+
299
385
  CONVERSIONS = {
300
386
  "collect_remote_ip": convert_to_bool,
301
387
  "core_agent_download": convert_to_bool,
302
388
  "core_agent_launch": convert_to_bool,
303
389
  "disabled_instruments": convert_to_list,
304
- "ignore": convert_to_list,
390
+ "ignore": convert_ignore_paths,
391
+ "ignore_endpoints": convert_ignore_paths,
392
+ "ignore_jobs": convert_ignore_paths,
305
393
  "monitor": convert_to_bool,
394
+ "sample_rate": convert_sample_rate,
395
+ "sample_endpoints": convert_endpoint_sampling,
396
+ "endpoint_sample_rate": convert_sample_rate,
397
+ "sample_jobs": convert_endpoint_sampling,
398
+ "job_sample_rate": convert_sample_rate,
306
399
  "shutdown_message_enabled": convert_to_bool,
307
400
  "shutdown_timeout_seconds": convert_to_float,
308
401
  }
@@ -4,7 +4,7 @@ import datetime as dt
4
4
  import sys
5
5
  from os import getpid
6
6
 
7
- from scout_apm.core.agent.commands import ApplicationEvent
7
+ from scout_apm.core.agent.commands import ApplicationEvent, format_dt_for_core_agent
8
8
  from scout_apm.core.agent.socket import CoreAgentSocketThread
9
9
  from scout_apm.core.config import scout_config
10
10
 
@@ -15,7 +15,7 @@ def report_app_metadata():
15
15
  event_type="scout.metadata",
16
16
  event_value=get_metadata(),
17
17
  source="Pid: " + str(getpid()),
18
- timestamp=dt.datetime.utcnow(),
18
+ timestamp=dt.datetime.now(dt.timezone.utc),
19
19
  )
20
20
  )
21
21
 
@@ -24,7 +24,7 @@ def get_metadata():
24
24
  data = {
25
25
  "language": "python",
26
26
  "language_version": "{}.{}.{}".format(*sys.version_info[:3]),
27
- "server_time": dt.datetime.utcnow().isoformat() + "Z",
27
+ "server_time": format_dt_for_core_agent(dt.datetime.now(dt.timezone.utc)),
28
28
  "framework": scout_config.value("framework"),
29
29
  "framework_version": scout_config.value("framework_version"),
30
30
  "environment": "",