Skip to content
This repository was archived by the owner on Sep 17, 2025. It is now read-only.

Commit e2cb2af

Browse files
authored
Support AAD authentication (#1021)
1 parent ee590ee commit e2cb2af

File tree

9 files changed

+194
-19
lines changed

9 files changed

+194
-19
lines changed

.isort.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ line_length=79
1313
; docs: https://github.com/timothycrosley/isort#multi-line-output-modes
1414
multi_line_output=3
1515
known_future_library = six,six.moves,__future__
16-
known_third_party=google,mock,pymysql,sqlalchemy,psycopg2,mysql,requests,django,pytest,grpc,flask,bitarray,prometheus_client,psutil,pymongo,wrapt,thrift,retrying,pyramid,werkzeug,gevent
16+
known_third_party=azure-core,azure-identity,google,mock,pymysql,sqlalchemy,psycopg2,mysql,requests,django,pytest,grpc,flask,bitarray,prometheus_client,psutil,pymongo,wrapt,thrift,retrying,pyramid,werkzeug,gevent
1717
known_first_party=opencensus

contrib/opencensus-ext-azure/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
## Unreleased
44

5+
- Enable AAD authorization via TokenCredential
6+
([#1021](https://github.com/census-instrumentation/opencensus-python/pull/1021))
7+
58
## 1.0.8
69
Released 2021-05-13
710

811
- Fix `logger.exception` with no exception info throwing error
912
([#1006](https://github.com/census-instrumentation/opencensus-python/pull/1006))
1013
- Add `enable_local_storage` to turn on/off local storage + retry + flushing logic
11-
([#1006](https://github.com/census-instrumentation/opencensus-python/pull/1006))
14+
([#1016](https://github.com/census-instrumentation/opencensus-python/pull/1016))
1215

1316
## 1.0.7
1417
Released 2021-01-25
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2021, OpenCensus Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from azure.identity import ClientSecretCredential
15+
16+
from opencensus.ext.azure.trace_exporter import AzureExporter
17+
from opencensus.trace.samplers import ProbabilitySampler
18+
from opencensus.trace.tracer import Tracer
19+
20+
tenant_id = "<tenant-id>"
21+
client_id = "<client-id>"
22+
client_secret = "<client-secret>"
23+
24+
credential = ClientSecretCredential(
25+
tenant_id=tenant_id,
26+
client_id=client_id,
27+
client_secret=client_secret
28+
)
29+
30+
tracer = Tracer(
31+
exporter=AzureExporter(
32+
credential=credential, connection_string="<your-connection-string>"),
33+
sampler=ProbabilitySampler(1.0)
34+
)
35+
36+
with tracer.span(name='foo'):
37+
print('Hello, World!')
38+
input(...)

contrib/opencensus-ext-azure/opencensus/ext/azure/common/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,14 @@ def process_options(options):
4646
endpoint = code_cs.get(INGESTION_ENDPOINT) \
4747
or env_cs.get(INGESTION_ENDPOINT) \
4848
or 'https://dc.services.visualstudio.com'
49-
options.endpoint = endpoint + '/v2/track'
49+
options.endpoint = endpoint
50+
51+
# Authorization
52+
# `azure.core.credentials.TokenCredential` class must be valid
53+
if options.credential and not hasattr(options.credential, 'get_token'):
54+
raise ValueError(
55+
'Must pass in valid TokenCredential.'
56+
)
5057

5158
# storage path
5259
if options.storage_path is None:
@@ -101,6 +108,7 @@ def __init__(self, *args, **kwargs):
101108

102109
_default = BaseObject(
103110
connection_string=None,
111+
credential=None,
104112
enable_local_storage=True,
105113
enable_standard_metrics=True,
106114
endpoint='https://dc.services.visualstudio.com/v2/track',

contrib/opencensus-ext-azure/opencensus/ext/azure/common/transport.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
import logging
1717

1818
import requests
19+
from azure.core.exceptions import ClientAuthenticationError
20+
from azure.identity._exceptions import CredentialUnavailableError
1921

2022
logger = logging.getLogger(__name__)
23+
_MONITOR_OAUTH_SCOPE = "https://monitor.azure.com//.default"
2124

2225

2326
class TransportMixin(object):
@@ -45,25 +48,45 @@ def _transmit(self, envelopes):
4548
if not envelopes:
4649
return 0
4750
try:
51+
headers = {
52+
'Accept': 'application/json',
53+
'Content-Type': 'application/json; charset=utf-8',
54+
}
55+
endpoint = self.options.endpoint
56+
if self.options.credential:
57+
token = self.options.credential.get_token(_MONITOR_OAUTH_SCOPE)
58+
headers["Authorization"] = "Bearer {}".format(token.token)
59+
# Use new api for aad scenario
60+
endpoint += '/v2.1/track'
61+
else:
62+
endpoint += '/v2/track'
4863
response = requests.post(
49-
url=self.options.endpoint,
64+
url=endpoint,
5065
data=json.dumps(envelopes),
51-
headers={
52-
'Accept': 'application/json',
53-
'Content-Type': 'application/json; charset=utf-8',
54-
},
66+
headers=headers,
5567
timeout=self.options.timeout,
5668
proxies=json.loads(self.options.proxies),
5769
)
5870
except requests.Timeout:
5971
logger.warning(
6072
'Request time out. Ingestion may be backed up. Retrying.')
6173
return self.options.minimum_retry_interval
62-
except Exception as ex: # TODO: consider RequestException
74+
except requests.RequestException as ex:
6375
logger.warning(
6476
'Retrying due to transient client side error %s.', ex)
6577
# client side error (retryable)
6678
return self.options.minimum_retry_interval
79+
except CredentialUnavailableError as ex:
80+
logger.warning('Credential error. %s. Dropping telemetry.', ex)
81+
return -1
82+
except ClientAuthenticationError as ex:
83+
logger.warning('Authentication error %s', ex)
84+
return self.options.minimum_retry_interval
85+
except Exception as ex:
86+
logger.warning(
87+
'Error when sending request %s. Dropping telemetry.', ex)
88+
# Extraneous error (non-retryable)
89+
return -1
6790

6891
text = 'N/A'
6992
data = None
@@ -120,6 +143,24 @@ def _transmit(self, envelopes):
120143
)
121144
# server side error (retryable)
122145
return self.options.minimum_retry_interval
146+
# Authentication error
147+
if response.status_code == 401:
148+
logger.warning(
149+
'Authentication error %s: %s.',
150+
response.status_code,
151+
text,
152+
)
153+
return self.options.minimum_retry_interval
154+
# Forbidden error
155+
# Can occur when v2 endpoint is used while AI resource is configured
156+
# with disableLocalAuth
157+
if response.status_code == 403:
158+
logger.warning(
159+
'Forbidden error %s: %s.',
160+
response.status_code,
161+
text,
162+
)
163+
return self.options.minimum_retry_interval
123164
logger.error(
124165
'Non-retryable server side error %s: %s.',
125166
response.status_code,

contrib/opencensus-ext-azure/setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
include_package_data=True,
4040
long_description=open('README.rst').read(),
4141
install_requires=[
42+
'azure-core >= 1.12.0, < 2.0.0',
43+
'azure-identity >= 1.5.0, < 2.0.0',
4244
'opencensus >= 0.8.dev0, < 1.0.0',
4345
'psutil >= 5.6.3',
4446
'requests >= 2.19.0',

contrib/opencensus-ext-azure/tests/test_options.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def test_process_options_endpoint_code_cs(self):
7171
'Authorization=ikey;IngestionEndpoint=456'
7272
common.process_options(options)
7373

74-
self.assertEqual(options.endpoint, '123/v2/track')
74+
self.assertEqual(options.endpoint, '123')
7575

7676
def test_process_options_endpoint_env_cs(self):
7777
options = common.Options()
@@ -80,15 +80,15 @@ def test_process_options_endpoint_env_cs(self):
8080
'Authorization=ikey;IngestionEndpoint=456'
8181
common.process_options(options)
8282

83-
self.assertEqual(options.endpoint, '456/v2/track')
83+
self.assertEqual(options.endpoint, '456')
8484

8585
def test_process_options_endpoint_default(self):
8686
options = common.Options()
8787
options.connection_string = None
8888
common.process_options(options)
8989

9090
self.assertEqual(options.endpoint,
91-
'https://dc.services.visualstudio.com/v2/track')
91+
'https://dc.services.visualstudio.com')
9292

9393
def test_process_options_proxies_default(self):
9494
options = common.Options()

contrib/opencensus-ext-azure/tests/test_transport_mixin.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@
1919

2020
import mock
2121
import requests
22+
from azure.core.exceptions import ClientAuthenticationError
23+
from azure.identity._exceptions import CredentialUnavailableError
2224

2325
from opencensus.ext.azure.common import Options
2426
from opencensus.ext.azure.common.storage import LocalFileStorage
25-
from opencensus.ext.azure.common.transport import TransportMixin
27+
from opencensus.ext.azure.common.transport import (
28+
_MONITOR_OAUTH_SCOPE,
29+
TransportMixin,
30+
)
2631

27-
TEST_FOLDER = os.path.abspath('.test.storage')
32+
TEST_FOLDER = os.path.abspath('.test.transport')
2833

2934

3035
def setUpModule():
@@ -68,6 +73,39 @@ def test_transmission_pre_timeout(self):
6873
self.assertIsNone(mixin.storage.get())
6974
self.assertEqual(len(os.listdir(mixin.storage.path)), 1)
7075

76+
def test_transmission_pre_req_exception(self):
77+
mixin = TransportMixin()
78+
mixin.options = Options()
79+
with LocalFileStorage(os.path.join(TEST_FOLDER, self.id())) as stor:
80+
mixin.storage = stor
81+
mixin.storage.put([1, 2, 3])
82+
with mock.patch('requests.post', throw(requests.RequestException)):
83+
mixin._transmit_from_storage()
84+
self.assertIsNone(mixin.storage.get())
85+
self.assertEqual(len(os.listdir(mixin.storage.path)), 1)
86+
87+
def test_transmission_cred_exception(self):
88+
mixin = TransportMixin()
89+
mixin.options = Options()
90+
with LocalFileStorage(os.path.join(TEST_FOLDER, self.id())) as stor:
91+
mixin.storage = stor
92+
mixin.storage.put([1, 2, 3])
93+
with mock.patch('requests.post', throw(CredentialUnavailableError)): # noqa: E501
94+
mixin._transmit_from_storage()
95+
self.assertIsNone(mixin.storage.get())
96+
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)
97+
98+
def test_transmission_client_exception(self):
99+
mixin = TransportMixin()
100+
mixin.options = Options()
101+
with LocalFileStorage(os.path.join(TEST_FOLDER, self.id())) as stor:
102+
mixin.storage = stor
103+
mixin.storage.put([1, 2, 3])
104+
with mock.patch('requests.post', throw(ClientAuthenticationError)):
105+
mixin._transmit_from_storage()
106+
self.assertIsNone(mixin.storage.get())
107+
self.assertEqual(len(os.listdir(mixin.storage.path)), 1)
108+
71109
def test_transmission_pre_exception(self):
72110
mixin = TransportMixin()
73111
mixin.options = Options()
@@ -77,7 +115,7 @@ def test_transmission_pre_exception(self):
77115
with mock.patch('requests.post', throw(Exception)):
78116
mixin._transmit_from_storage()
79117
self.assertIsNone(mixin.storage.get())
80-
self.assertEqual(len(os.listdir(mixin.storage.path)), 1)
118+
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)
81119

82120
@mock.patch('requests.post', return_value=mock.Mock())
83121
def test_transmission_lease_failure(self, requests_mock):
@@ -120,6 +158,40 @@ def test_transmission_200(self):
120158
self.assertIsNone(mixin.storage.get())
121159
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)
122160

161+
def test_transmission_auth(self):
162+
mixin = TransportMixin()
163+
mixin.options = Options()
164+
url = 'https://dc.services.visualstudio.com'
165+
mixin.options.endpoint = url
166+
credential = mock.Mock()
167+
mixin.options.credential = credential
168+
token_mock = mock.Mock()
169+
token_mock.token = "test_token"
170+
credential.get_token.return_value = token_mock
171+
data = '[1, 2, 3]'
172+
headers = {
173+
'Accept': 'application/json',
174+
'Content-Type': 'application/json; charset=utf-8',
175+
'Authorization': 'Bearer test_token',
176+
}
177+
with LocalFileStorage(os.path.join(TEST_FOLDER, self.id())) as stor:
178+
mixin.storage = stor
179+
mixin.storage.put([1, 2, 3])
180+
with mock.patch('requests.post') as post:
181+
post.return_value = MockResponse(200, 'unknown')
182+
mixin._transmit_from_storage()
183+
post.assert_called_with(
184+
url=url + '/v2.1/track',
185+
data=data,
186+
headers=headers,
187+
timeout=10.0,
188+
proxies={}
189+
)
190+
credential.get_token.assert_called_with(_MONITOR_OAUTH_SCOPE)
191+
self.assertIsNone(mixin.storage.get())
192+
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)
193+
credential.get_token.assert_called_once()
194+
123195
def test_transmission_206(self):
124196
mixin = TransportMixin()
125197
mixin.options = Options()
@@ -201,16 +273,16 @@ def test_transmission_206_bogus(self):
201273
self.assertIsNone(mixin.storage.get())
202274
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)
203275

204-
def test_transmission_400(self):
276+
def test_transmission_401(self):
205277
mixin = TransportMixin()
206278
mixin.options = Options()
207279
with LocalFileStorage(os.path.join(TEST_FOLDER, self.id())) as stor:
208280
mixin.storage = stor
209281
mixin.storage.put([1, 2, 3])
210282
with mock.patch('requests.post') as post:
211-
post.return_value = MockResponse(400, '{}')
283+
post.return_value = MockResponse(401, '{}')
212284
mixin._transmit_from_storage()
213-
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)
285+
self.assertEqual(len(os.listdir(mixin.storage.path)), 1)
214286

215287
def test_transmission_500(self):
216288
mixin = TransportMixin()
@@ -223,3 +295,14 @@ def test_transmission_500(self):
223295
mixin._transmit_from_storage()
224296
self.assertIsNone(mixin.storage.get())
225297
self.assertEqual(len(os.listdir(mixin.storage.path)), 1)
298+
299+
def test_transmission_400(self):
300+
mixin = TransportMixin()
301+
mixin.options = Options()
302+
with LocalFileStorage(os.path.join(TEST_FOLDER, self.id())) as stor:
303+
mixin.storage = stor
304+
mixin.storage.put([1, 2, 3])
305+
with mock.patch('requests.post') as post:
306+
post.return_value = MockResponse(400, '{}')
307+
mixin._transmit_from_storage()
308+
self.assertEqual(len(os.listdir(mixin.storage.path)), 0)

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ deps =
5050
docs: sphinx >= 1.6.3
5151

5252
commands =
53-
unit: py.test --quiet --cov={envdir}/opencensus --cov=context --cov=contrib --cov-report term-missing --cov-config=.coveragerc --cov-fail-under=97 tests/unit/ context/ contrib/
53+
unit: py.test --quiet --cov={envdir}/opencensus --cov=context --cov=contrib --cov-report term-missing --cov-config=.coveragerc --cov-fail-under=97 --ignore=contrib/opencensus-ext-datadog tests/unit/ context/ contrib/
5454
; TODO system tests
5555
lint: isort --check-only --diff --recursive .
5656
lint: flake8 context/ contrib/ opencensus/ tests/ examples/

0 commit comments

Comments
 (0)