Skip to content

Commit 4784314

Browse files
author
Juliano Vieira
committed
adding SPAN_MIN_DURATION config
1 parent c6bb9ee commit 4784314

10 files changed

Lines changed: 139 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pip-log.txt
1414
/build
1515
/cover
1616
/dist
17+
.venv
1718
/example_project/local_settings.py
1819
/docs/html
1920
/docs/doctrees

AGENTS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
Core agent code lives in `elasticapm/`, with feature areas split into packages such as `contrib/`, `instrumentation/`, `transport/`, `metrics/`, and `utils/`. Tests live under `tests/` and are grouped by subsystem, for example `tests/instrumentation/`, `tests/contrib/`, and `tests/config/`. Test-only helpers and dependency sets are in `tests/fixtures.py`, `tests/requirements/`, and `tests/scripts/`. CI matrix files are under `.ci/`, and end-user documentation is in `docs/`.
5+
6+
## Build, Test, and Development Commands
7+
Use the repo `Makefile` for the standard workflow:
8+
9+
- `make test`: clears Python caches, then runs `pytest -v --showlocals`.
10+
- `make coverage`: runs the test suite with branch coverage enabled.
11+
- `make flake8`: runs lint checks.
12+
- `make isort`: sorts imports across the repository.
13+
- `make docs`: builds docs from `docs/` into `build/`.
14+
15+
For local setup, install the dependency set relevant to your area first, for example `pip install -r tests/requirements/reqs-flask-1.1.txt`. Install hooks with `pre-commit install`.
16+
17+
## Coding Style & Naming Conventions
18+
Python uses 4-space indentation, LF line endings, and UTF-8 per `.editorconfig`; YAML and `.feature` files use 2 spaces. Format with `black` and keep lines at 120 characters. Keep imports ordered with `isort`, then run `flake8`. Module and function names use `snake_case`; classes use `PascalCase`. Follow existing test file patterns such as `*_tests.py` and `test_*.py`.
19+
20+
## Testing Guidelines
21+
Pytest is the test runner, with random ordering enabled by default. Favor focused runs while developing, for example `pytest tests/instrumentation/httpx_tests.py -m httpx`. Mark integration-heavy cases with the existing pytest markers from `setup.cfg`, and use `pytest.importorskip()` for optional dependencies. Add new dependency-specific requirements in `tests/requirements/` and matching environment scripts in `tests/scripts/envs/` when expanding the matrix.
22+
23+
## Commit & Pull Request Guidelines
24+
Recent history follows concise, Conventional Commit-style subjects such as `fix: ...` and `build(deps): ...`; keep commit messages short and scoped. Open PRs against `main`, link the related issue (`Closes #123`), summarize behavior changes, and note any test coverage added. Expect maintainers to squash-merge, so keep branch history clean and rebased.

docs/reference/configuration.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,24 @@ if a span propagates distributed tracing IDs, it will not be ignored, even if it
610610
::::
611611

612612

613+
### `span_min_duration` [config-span-min-duration]
614+
615+
[![dynamic config](images/dynamic-config.svg "") ](#dynamic-configuration)
616+
617+
| Environment | Django/Flask | Default |
618+
| --- | --- | --- |
619+
| `ELASTIC_APM_SPAN_MIN_DURATION` | `SPAN_MIN_DURATION` | `"0ms"` |
620+
621+
Spans shorter than this threshold can be ignored. This applies to successful spans in general.
622+
623+
For leaf/exit spans, [`exit_span_min_duration`](#config-exit-span-min-duration) takes precedence when it is configured.
624+
625+
This feature is disabled by default.
626+
627+
::::{note}
628+
If a span propagates distributed tracing IDs, it will not be ignored, even if it is shorter than the configured threshold. This is to ensure that no broken traces are recorded.
629+
::::
630+
613631

614632
### `api_request_size` [config-api-request-size]
615633

@@ -1086,4 +1104,3 @@ The *size* format is used for options like maximum buffer sizes. The unit is pro
10861104
We use the power-of-two sizing convention, e.g. `1 kilobyte == 1024 bytes`
10871105
::::
10881106

1089-

docs/reference/performance-tuning.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ The average amount of spans per transaction can influence how much time the agen
6060

6161
To avoid these edge cases overloading both the agent and the APM Server, the agent stops recording spans when a specified limit is reached. You can configure this limit by changing the [`transaction_max_spans`](/reference/configuration.md#config-transaction-max-spans) setting.
6262

63+
You can also ignore very short spans by configuring [`span_min_duration`](/reference/configuration.md#config-span-min-duration). If you only want to target leaf/exit spans, use [`exit_span_min_duration`](/reference/configuration.md#config-exit-span-min-duration).
64+
6365

6466
## Span Stack Trace Collection [tuning-span-stack-trace-collection]
6567

@@ -89,4 +91,3 @@ Reading source files inside a running application can cause a lot of disk I/O, a
8991
You can configure the Elastic APM agent to capture headers of both requests and responses ([`capture_headers`](/reference/configuration.md#config-capture-headers)), as well as request bodies ([`capture_body`](/reference/configuration.md#config-capture-body)). By default, capturing request bodies is disabled. Enabling it for transactions may introduce noticeable overhead, as well as increased storage use, depending on the nature of your POST requests. In most scenarios, we advise against enabling request body capturing for transactions, and only enable it if necessary for errors.
9092

9193
Capturing request/response headers has less overhead on the agent, but can have an impact on storage use. If storage use is a problem for you, it might be worth disabling.
92-

elasticapm/conf/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,10 @@ class Config(_ConfigBase):
678678
"SPAN_COMPRESSION_SAME_KIND_MAX_DURATION",
679679
default=timedelta(seconds=0),
680680
)
681+
span_min_duration = _DurationConfigValue(
682+
"SPAN_MIN_DURATION",
683+
default=timedelta(seconds=0),
684+
)
681685
exit_span_min_duration = _DurationConfigValue(
682686
"EXIT_SPAN_MIN_DURATION",
683687
default=timedelta(seconds=0),

elasticapm/traces.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ def __init__(
229229
self.config_span_compression_enabled = tracer.config.span_compression_enabled
230230
self.config_span_compression_exact_match_max_duration = tracer.config.span_compression_exact_match_max_duration
231231
self.config_span_compression_same_kind_max_duration = tracer.config.span_compression_same_kind_max_duration
232+
self.config_span_min_duration = tracer.config.span_min_duration
232233
self.config_exit_span_min_duration = tracer.config.exit_span_min_duration
233234
self.config_transaction_max_spans = tracer.config.transaction_max_spans
234235

@@ -675,6 +676,16 @@ def is_compression_eligible(self) -> bool:
675676
def discardable(self) -> bool:
676677
return self.leaf and not self.dist_tracing_propagated and self.outcome == constants.OUTCOME.SUCCESS
677678

679+
@property
680+
def duration_discardable(self) -> bool:
681+
return not self.dist_tracing_propagated and self.outcome == constants.OUTCOME.SUCCESS
682+
683+
@property
684+
def min_duration(self) -> timedelta:
685+
if self.leaf and self.transaction.config_exit_span_min_duration > timedelta(seconds=0):
686+
return self.transaction.config_exit_span_min_duration
687+
return self.transaction.config_span_min_duration
688+
678689
def end(self, skip_frames: int = 0, duration: Optional[float] = None) -> None:
679690
"""
680691
End this span and queue it for sending.
@@ -710,7 +721,7 @@ def end(self, skip_frames: int = 0, duration: Optional[float] = None) -> None:
710721
p.child_ended(self)
711722

712723
def report(self) -> None:
713-
if self.discardable and self.duration < self.transaction.config_exit_span_min_duration:
724+
if self.duration_discardable and self.duration < self.min_duration:
714725
self.transaction.track_dropped_span(self)
715726
self.transaction.dropped_spans += 1
716727
elif self._cancelled:

tests/client/dropped_spans_tests.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,68 @@ def test_transaction_fast_exit_span(elasticapm_client):
154154
assert metrics[1]["samples"]["span.self_time.sum.us"]["value"] == 100
155155

156156

157+
@pytest.mark.parametrize("elasticapm_client", [{"span_min_duration": "1ms"}], indirect=True)
158+
def test_transaction_fast_span(elasticapm_client):
159+
elasticapm_client.begin_transaction("test_type")
160+
with elasticapm.capture_span(span_type="x", name="x", leaf=False, duration=0.002): # not dropped, too long
161+
pass
162+
with elasticapm.capture_span(span_type="y", name="y", leaf=False, duration=0.0001): # dropped
163+
pass
164+
elasticapm_client.end_transaction("foo", duration=2.2)
165+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
166+
spans = elasticapm_client.events[constants.SPAN]
167+
assert len(spans) == 1
168+
assert spans[0]["name"] == "x"
169+
assert transaction["span_count"]["started"] == 2
170+
assert transaction["span_count"]["dropped"] == 1
171+
172+
173+
@pytest.mark.parametrize(
174+
"elasticapm_client", [{"span_min_duration": "10ms", "exit_span_min_duration": "1ms"}], indirect=True
175+
)
176+
def test_transaction_fast_span_exit_threshold_overrides(elasticapm_client):
177+
elasticapm_client.begin_transaction("test_type")
178+
with elasticapm.capture_span(span_type="x", name="leaf", leaf=True, duration=0.002):
179+
pass
180+
with elasticapm.capture_span(span_type="y", name="non-leaf", leaf=False, duration=0.002):
181+
pass
182+
elasticapm_client.end_transaction("foo", duration=2.2)
183+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
184+
spans = elasticapm_client.events[constants.SPAN]
185+
assert len(spans) == 1
186+
assert spans[0]["name"] == "leaf"
187+
assert transaction["span_count"]["started"] == 2
188+
assert transaction["span_count"]["dropped"] == 1
189+
190+
191+
@pytest.mark.parametrize("elasticapm_client", [{"span_min_duration": "1ms"}], indirect=True)
192+
def test_transaction_fast_span_not_dropped_on_failure(elasticapm_client):
193+
elasticapm_client.begin_transaction("test_type")
194+
with pytest.raises(ValueError):
195+
with elasticapm.capture_span(span_type="x", name="x", leaf=False, duration=0.0001):
196+
raise ValueError()
197+
elasticapm_client.end_transaction("foo", duration=2.2)
198+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
199+
spans = elasticapm_client.events[constants.SPAN]
200+
assert len(spans) == 1
201+
assert spans[0]["outcome"] == constants.OUTCOME.FAILURE
202+
assert transaction["span_count"]["started"] == 1
203+
assert transaction["span_count"]["dropped"] == 0
204+
205+
206+
@pytest.mark.parametrize("elasticapm_client", [{"span_min_duration": "1ms"}], indirect=True)
207+
def test_transaction_fast_span_not_dropped_when_distributed_tracing_propagated(elasticapm_client):
208+
elasticapm_client.begin_transaction("test_type")
209+
with elasticapm.capture_span(span_type="x", name="x", leaf=False, duration=0.0001) as span:
210+
span.dist_tracing_propagated = True
211+
elasticapm_client.end_transaction("foo", duration=2.2)
212+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
213+
spans = elasticapm_client.events[constants.SPAN]
214+
assert len(spans) == 1
215+
assert transaction["span_count"]["started"] == 1
216+
assert transaction["span_count"]["dropped"] == 0
217+
218+
157219
def test_transaction_cancelled_span(elasticapm_client):
158220
elasticapm_client.begin_transaction("test_type")
159221
with elasticapm.capture_span("test") as span:

tests/config/config_snapshotting_tests.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,17 @@ def test_config_snapshotting_span_compression_drop_exit_span(elasticapm_client):
123123
assert len(spans) == 0
124124

125125

126+
def test_config_snapshotting_span_compression_drop_span(elasticapm_client):
127+
elasticapm_client.config.update(version="1", span_min_duration="10ms")
128+
elasticapm_client.begin_transaction("foo")
129+
elasticapm_client.config.update(version="2", span_min_duration="0ms")
130+
with elasticapm.capture_span("x", leaf=False, span_type="a", span_subtype="b", span_action="c", duration=0.005):
131+
pass
132+
elasticapm_client.end_transaction()
133+
spans = elasticapm_client.events[SPAN]
134+
assert len(spans) == 0
135+
136+
126137
def test_config_snapshotting_span_compression_max_spans(elasticapm_client):
127138
elasticapm_client.config.update(version="1", transaction_max_spans="1")
128139
elasticapm_client.begin_transaction("foo")

tests/contrib/django/fixtures.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def django_elasticapm_client(request):
5959
client_config.setdefault("span_stack_trace_min_duration", 0)
6060
client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
6161
client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
62+
client_config.setdefault("span_min_duration", "0ms")
6263
app = apps.get_app_config("elasticapm")
6364
old_client = app.client
6465
client = TempStoreClient(**client_config)
@@ -88,6 +89,7 @@ def django_sending_elasticapm_client(request, validating_httpserver):
8889
client_config.setdefault("span_stack_trace_min_duration", 0)
8990
client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
9091
client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
92+
client_config.setdefault("span_min_duration", "0ms")
9193
client_config.setdefault("exit_span_min_duration", "0ms")
9294
app = apps.get_app_config("elasticapm")
9395
old_client = app.client

tests/fixtures.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ def elasticapm_client(request):
227227
client_config.setdefault("cloud_provider", False)
228228
client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
229229
client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
230+
client_config.setdefault("span_min_duration", "0ms")
230231
client_config.setdefault("exit_span_min_duration", "0ms")
231232
client = client_class(**client_config)
232233
yield client
@@ -264,6 +265,7 @@ def elasticapm_client_log_file(request):
264265
client_config.setdefault("span_stack_trace_min_duration", 0)
265266
client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
266267
client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
268+
client_config.setdefault("span_min_duration", "0ms")
267269
client_config.setdefault("metrics_interval", "0ms")
268270
client_config.setdefault("cloud_provider", False)
269271
client_config.setdefault("log_level", "warning")
@@ -349,6 +351,7 @@ def sending_elasticapm_client(request, validating_httpserver):
349351
client_config.setdefault("span_stack_trace_min_duration", 0)
350352
client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
351353
client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
354+
client_config.setdefault("span_min_duration", "0ms")
352355
client_config.setdefault("include_paths", ("*/tests/*",))
353356
client_config.setdefault("metrics_interval", "0ms")
354357
client_config.setdefault("cloud_provider", False)

0 commit comments

Comments
 (0)