Skip to content

Commit a0e0840

Browse files
committed
Changes to accommodate for environment changes
- Adding `CoverageSpanFilter` for different types of filter on what is going to be covered - Adding `UnableToStartProcessorException` for when we are unable to start the tracking - Adding new flow where we post to create a version ahead of time, and then we can just use the external_id on future calls
1 parent 12df2e6 commit a0e0840

File tree

3 files changed

+150
-42
lines changed

3 files changed

+150
-42
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ ipython_config.py
8585
# pyenv
8686
# For a library or package, you might want to ignore these files since the code is
8787
# intended to run in multiple environments; otherwise, check them in:
88-
# .python-version
88+
.python-version
8989

9090
# pipenv
9191
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.

codecovopentelem/__init__.py

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import urllib.parse
55
from base64 import b64encode
66
from decimal import Decimal
7+
from enum import Enum
78
from io import StringIO
8-
from typing import Optional, Tuple, Pattern
9+
from typing import Dict, Optional, Pattern, Tuple
910

1011
import coverage
1112
import requests
@@ -16,65 +17,81 @@
1617
log = logging.getLogger("codecovopentelem")
1718

1819

20+
class CoverageSpanFilter(Enum):
21+
regex_name_filter = "name_regex"
22+
span_kind_filter = "span_kind"
23+
24+
25+
class UnableToStartProcessorException(Exception):
26+
pass
27+
28+
1929
class CodecovCoverageStorageManager(object):
20-
def __init__(self, writeable_folder: str):
30+
def __init__(self, writeable_folder: str, filters: Dict):
2131
if writeable_folder is None:
2232
writeable_folder = "/home/codecov"
2333
self._writeable_folder = writeable_folder
2434
self.inner = {}
35+
self._filters = filters
2536

26-
def start_cov_for_span(self, span_id):
37+
def possibly_start_cov_for_span(self, span) -> bool:
38+
span_id = span.context.span_id
39+
if self._filters.get(
40+
CoverageSpanFilter.regex_name_filter
41+
) and not self._filters.get(CoverageSpanFilter.regex_name_filter).match(
42+
span.name
43+
):
44+
return False
45+
if self._filters.get(
46+
CoverageSpanFilter.span_kind_filter
47+
) and span.kind not in self._filters.get(CoverageSpanFilter.span_kind_filter):
48+
return False
2749
cov = coverage.Coverage(data_file=f"{self._writeable_folder}/.{span_id}file")
2850
self.inner[span_id] = cov
2951
cov.start()
52+
return True
3053

31-
def stop_cov_for_span(self, span_id):
54+
def stop_cov_for_span(self, span):
55+
span_id = span.context.span_id
3256
cov = self.inner.get(span_id)
3357
if cov is not None:
3458
cov.stop()
3559

36-
def pop_cov_for_span(self, span_id):
60+
def pop_cov_for_span(self, span):
61+
span_id = span.context.span_id
3762
return self.inner.pop(span_id, None)
3863

3964

4065
class CodecovCoverageGenerator(SpanProcessor):
4166
def __init__(
42-
self,
43-
cov_storage: CodecovCoverageStorageManager,
44-
sample_rate: Decimal,
45-
name_regex: Pattern = None,
67+
self, cov_storage: CodecovCoverageStorageManager, sample_rate: Decimal,
4668
):
4769
self._cov_storage = cov_storage
4870
self._sample_rate = sample_rate
49-
self._name_regex = name_regex
5071

5172
def _should_profile_span(self, span, parent_context):
52-
return random.random() < self._sample_rate and (
53-
self._name_regex is None or self._name_regex.match(span.name)
54-
)
73+
return random.random() < self._sample_rate
5574

5675
def on_start(self, span, parent_context=None):
5776
if self._should_profile_span(span, parent_context):
58-
span_id = span.context.span_id
59-
self._cov_storage.start_cov_for_span(span_id)
77+
self._cov_storage.possibly_start_cov_for_span(span)
6078

6179
def on_end(self, span):
62-
span_id = span.context.span_id
63-
self._cov_storage.stop_cov_for_span(span_id)
80+
self._cov_storage.stop_cov_for_span(span)
6481

6582

6683
class CoverageExporter(SpanExporter):
6784
def __init__(
6885
self,
6986
cov_storage: CodecovCoverageStorageManager,
7087
repository_token: str,
71-
profiling_identifier: str,
88+
profiling_id: str,
7289
codecov_endpoint: str,
7390
untracked_export_rate: float,
7491
):
7592
self._cov_storage = cov_storage
7693
self._repository_token = repository_token
77-
self._profiling_identifier = profiling_identifier
94+
self._profiling_id = profiling_id
7895
self._codecov_endpoint = codecov_endpoint
7996
self._untracked_export_rate = untracked_export_rate
8097

@@ -96,71 +113,98 @@ def export(self, spans):
96113
tracked_spans = []
97114
untracked_spans = []
98115
for span in spans:
99-
span_id = span.context.span_id
100-
cov = self._cov_storage.pop_cov_for_span(span_id)
116+
cov = self._cov_storage.pop_cov_for_span(span)
101117
s = json.loads(span.to_json())
102118
if cov is not None:
103119
s["codecov"] = self._load_codecov_dict(span, cov)
104120
tracked_spans.append(s)
105121
else:
106122
if random.random() < self._untracked_export_rate:
107123
untracked_spans.append(s)
108-
if not tracked_spans:
124+
if not tracked_spans and not untracked_spans:
109125
return SpanExportResult.SUCCESS
110126
url = urllib.parse.urljoin(self._codecov_endpoint, "/profiling/uploads")
111-
res = requests.post(
112-
url,
113-
headers={"Authorization": f"repotoken {self._repository_token}"},
114-
json={"profiling": self._profiling_identifier},
115-
)
116127
try:
128+
res = requests.post(
129+
url,
130+
headers={"Authorization": f"repotoken {self._repository_token}"},
131+
json={"profiling": self._profiling_id},
132+
)
117133
res.raise_for_status()
118-
except requests.HTTPError:
134+
except requests.RequestException:
119135
log.warning("Unable to send profiling data to codecov")
120136
return SpanExportResult.FAILURE
121137
location = res.json()["raw_upload_location"]
122138
requests.put(
123139
location,
124140
headers={"Content-Type": "application/txt"},
125-
data=json.dumps({"spans": tracked_spans, "untracked": untracked_spans}).encode(),
141+
data=json.dumps(
142+
{"spans": tracked_spans, "untracked": untracked_spans}
143+
).encode(),
126144
)
127145
return SpanExportResult.SUCCESS
128146

129147

130148
def get_codecov_opentelemetry_instances(
131149
repository_token: str,
132-
profiling_identifier: str,
133150
sample_rate: float,
134-
name_regex: Optional[Pattern],
151+
untracked_export_rate: float,
152+
filters: Optional[Dict] = None,
153+
profiling_identifier: Optional[str] = None,
154+
environment: Optional[str] = None,
155+
profiling_id: Optional[str] = None,
135156
codecov_endpoint: str = None,
136157
writeable_folder: str = None,
137158
) -> Tuple[CodecovCoverageGenerator, CoverageExporter]:
138159
"""
139160
Entrypoint for getting a span processor/span exporter
140161
pair for getting profiling data into codecov
141162
163+
Notice that either `profiling_id` or `profiling_identifier` and `environment` need to be set.
164+
If `profiling_id` is set, we just use it directly on the exporter. If not, we will use
165+
`profiling_identifier` and `environment` to generate fetch a `profiling_id` from the
166+
database
167+
142168
Args:
143169
repository_token (str): The profiling-capable authentication token
144-
profiling_identifier (str): The identifier for what profiling one is doing
145170
sample_rate (float): The sampling rate for codecov
146-
name_regex (Optional[Pattern]): A regex to filter which spans should be
147-
sampled
171+
untracked_export_rate (float): Description
172+
filters (Optional[Dict], optional): A dictionary of filters for determining which
173+
spans should have its coverage tracked
174+
profiling_identifier (Optional[str], optional): The identifier for what profiling one is doing
175+
environment (Optional[str], optional): Which environment this profiling is running on
176+
profiling_id (Optional[str], optional): Description
148177
codecov_endpoint (str, optional): For configuring the endpoint in case
149178
the user is in enterprise (not supported yet). Default is "https://api.codecov.io/"
150179
writeable_folder (str, optional): A folder that is guaranteed to be write-able
151180
in the system. It's only used for temporary files, and nothing is expected
152181
to live very long in there.
153182
"""
154-
if codecov_endpoint is None:
155-
codecov_endpoint = "https://api.codecov.io"
156-
manager = CodecovCoverageStorageManager(writeable_folder)
157-
generator = CodecovCoverageGenerator(manager, sample_rate, name_regex)
158-
# untracked rate set to make it so we export roughly as many tracked and untracked spans
159-
untracked_export_rate = sample_rate / (1 - sample_rate) if sample_rate < 1 else 0
183+
codecov_endpoint = codecov_endpoint or "https://api.codecov.io"
184+
if profiling_id is None:
185+
if profiling_identifier is None or environment is None:
186+
raise UnableToStartProcessorException(
187+
"Codecov profiling needs either the id or identifier + environment"
188+
)
189+
response = requests.post(
190+
urllib.parse.urljoin(codecov_endpoint, "/profiling/versions"),
191+
json={
192+
"version_identifier": profiling_identifier,
193+
"environment": environment,
194+
},
195+
headers={"Authorization": f"repotoken {repository_token}"},
196+
)
197+
try:
198+
response.raise_for_status()
199+
except requests.HTTPError:
200+
raise UnableToStartProcessorException()
201+
profiling_id = response.json()["external_id"]
202+
manager = CodecovCoverageStorageManager(writeable_folder, filters or {})
203+
generator = CodecovCoverageGenerator(manager, sample_rate)
160204
exporter = CoverageExporter(
161205
manager,
162206
repository_token,
163-
profiling_identifier,
207+
profiling_id,
164208
codecov_endpoint,
165209
untracked_export_rate,
166210
)

tests/test_interface.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import json
2+
from uuid import uuid4
3+
4+
import pytest
5+
import responses
6+
from coverage import Coverage
7+
from coverage.xmlreport import XmlReporter
8+
9+
from codecovopentelem import (
10+
CodecovCoverageGenerator,
11+
CoverageExporter,
12+
get_codecov_opentelemetry_instances,
13+
UnableToStartProcessorException,
14+
)
15+
16+
17+
@pytest.fixture
18+
def mocked_responses():
19+
with responses.RequestsMock() as rsps:
20+
yield rsps
21+
22+
23+
def test_get_codecov_opentelemetry_instances_nothing_set(mocker, mocked_responses):
24+
with pytest.raises(UnableToStartProcessorException) as exc:
25+
get_codecov_opentelemetry_instances(
26+
repository_token="repository_token",
27+
sample_rate=0.1,
28+
untracked_export_rate=0.1,
29+
)
30+
assert exc.value.args == (
31+
"Codecov profiling needs either the id or identifier + environment",
32+
)
33+
34+
35+
def test_get_codecov_opentelemetry_instances_nothing_set_env_and_version(
36+
mocker, mocked_responses
37+
):
38+
uuid = uuid4().hex
39+
mocked_responses.add(
40+
responses.POST,
41+
"https://api.codecov.io/profiling/versions",
42+
json={"external_id": uuid},
43+
status=200,
44+
content_type="application/json",
45+
match=[
46+
responses.matchers.json_params_matcher(
47+
{
48+
"version_identifier": "profiling_identifier",
49+
"environment": "production",
50+
}
51+
)
52+
],
53+
)
54+
res = get_codecov_opentelemetry_instances(
55+
repository_token="repository_token",
56+
sample_rate=0.1,
57+
untracked_export_rate=0.1,
58+
profiling_identifier="profiling_identifier",
59+
environment="production",
60+
)
61+
assert len(res) == 2
62+
generator, exporter = res
63+
assert isinstance(generator, CodecovCoverageGenerator)
64+
assert isinstance(exporter, CoverageExporter)

0 commit comments

Comments
 (0)