Skip to content

Commit fdc1577

Browse files
committed
Add session id to user agent string
1 parent 0558262 commit fdc1577

File tree

3 files changed

+243
-1
lines changed

3 files changed

+243
-1
lines changed

awscli/clidriver.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
set_stream_logger,
7676
)
7777
from awscli.plugin import load_plugins
78+
from awscli.telemetry import add_session_id_component_to_user_agent_extra
7879
from awscli.utils import (
7980
IMDSRegionProvider,
8081
OutputStreamFactory,
@@ -176,6 +177,7 @@ def _set_user_agent_for_session(session):
176177
session.user_agent_version = __version__
177178
_add_distribution_source_to_user_agent(session)
178179
_add_linux_distribution_to_user_agent(session)
180+
add_session_id_component_to_user_agent_extra(session)
179181

180182

181183
def no_pager_handler(session, parsed_args, **kwargs):

awscli/telemetry.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
import datetime
14+
import hashlib
15+
import io
16+
import os
17+
import socket
18+
import sqlite3
19+
import sys
20+
import threading
21+
from dataclasses import dataclass
22+
from functools import cached_property
23+
from pathlib import Path
24+
25+
from botocore.useragent import UserAgentComponent
26+
27+
from awscli.compat import is_windows
28+
from awscli.utils import add_component_to_user_agent_extra
29+
30+
_CACHE_DIR = Path('~/.aws/cli/cache').expanduser()
31+
_DATABASE_FILENAME = 'session.db'
32+
_SESSION_LENGTH_SECONDS = 60 * 30
33+
34+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
35+
36+
37+
@dataclass
38+
class CLISessionData:
39+
key: str
40+
session_id: str
41+
timestamp: int
42+
43+
44+
class CLISessionDatabaseConnection:
45+
_CREATE_TABLE = """
46+
CREATE TABLE IF NOT EXISTS session (
47+
key TEXT PRIMARY KEY,
48+
session_id TEXT NOT NULL,
49+
timestamp INTEGER NOT NULL
50+
)
51+
"""
52+
_ENABLE_WAL = 'PRAGMA journal_mode=WAL'
53+
54+
def __init__(self):
55+
self._connection = sqlite3.connect(
56+
_CACHE_DIR / _DATABASE_FILENAME,
57+
check_same_thread=False,
58+
isolation_level=None,
59+
)
60+
self._ensure_database_setup()
61+
62+
def execute(self, query, *parameters):
63+
try:
64+
return self._connection.execute(query, *parameters)
65+
except sqlite3.OperationalError:
66+
# Process timed out waiting for database lock.
67+
# Return any empty `Cursor` object instead of
68+
# raising an exception.
69+
return sqlite3.Cursor(self._connection)
70+
71+
def _ensure_database_setup(self):
72+
self._create_record_table()
73+
self._try_to_enable_wal()
74+
75+
def _create_record_table(self):
76+
self.execute(self._CREATE_TABLE)
77+
78+
def _try_to_enable_wal(self):
79+
try:
80+
self.execute(self._ENABLE_WAL)
81+
except sqlite3.Error:
82+
# This is just a performance enhancement so it is optional. Not all
83+
# systems will have a sqlite compiled with the WAL enabled.
84+
pass
85+
86+
87+
class CLISessionDatabaseWriter:
88+
_WRITE_RECORD = """
89+
INSERT OR REPLACE INTO session (
90+
key, session_id, timestamp
91+
) VALUES (?, ?, ?)
92+
"""
93+
94+
def __init__(self, connection):
95+
self._connection = connection
96+
97+
def write(self, data):
98+
self._connection.execute(
99+
self._WRITE_RECORD,
100+
(
101+
data.key,
102+
data.session_id,
103+
data.timestamp,
104+
),
105+
)
106+
107+
108+
class CLISessionDatabaseReader:
109+
_READ_RECORD = """
110+
SELECT *
111+
FROM session
112+
WHERE key = ?
113+
"""
114+
115+
def __init__(self, connection):
116+
self._connection = connection
117+
118+
def read(self, key):
119+
cursor = self._connection.execute(self._READ_RECORD, (key,))
120+
result = cursor.fetchone()
121+
if result is None:
122+
return
123+
return CLISessionData(*result)
124+
125+
126+
class CLISessionDatabaseSweeper:
127+
_DELETE_RECORDS = """
128+
DELETE FROM session
129+
WHERE timestamp < ?
130+
"""
131+
132+
def __init__(self, connection):
133+
self._connection = connection
134+
135+
def sweep(self, timestamp):
136+
try:
137+
self._connection.execute(self._DELETE_RECORDS, (timestamp,))
138+
except Exception:
139+
# This is just a background cleanup task. No need to
140+
# handle it or direct to stderr.
141+
return
142+
143+
144+
class CLISessionGenerator:
145+
def generate_session_id(self, hostname, tty, timestamp):
146+
return self._generate_md5_hash(hostname, tty, timestamp)
147+
148+
def generate_cache_key(self, hostname, tty):
149+
return self._generate_md5_hash(hostname, tty)
150+
151+
def _generate_md5_hash(self, *args):
152+
str_to_hash = ""
153+
for arg in args:
154+
if arg is not None:
155+
str_to_hash += str(arg)
156+
return hashlib.md5(str_to_hash.encode('utf-8')).hexdigest()
157+
158+
159+
class CLISessionOrchestrator:
160+
def __init__(self, generator, writer, reader, sweeper):
161+
self._generator = generator
162+
self._writer = writer
163+
self._reader = reader
164+
self._sweeper = sweeper
165+
166+
self._sweep_cache()
167+
168+
@cached_property
169+
def session_id(self):
170+
cache_key = self._generator.generate_cache_key(
171+
self._hostname, self._tty
172+
)
173+
if (cached_data := self._reader.read(cache_key)) is not None:
174+
cached_data.timestamp = self._timestamp
175+
self._writer.write(cached_data)
176+
return cached_data.session_id
177+
session_id = self._generator.generate_session_id(
178+
self._hostname, self._tty, self._timestamp
179+
)
180+
session_data = CLISessionData(cache_key, session_id, self._timestamp)
181+
self._writer.write(session_data)
182+
return session_id
183+
184+
@cached_property
185+
def _tty(self):
186+
# os.ttyname is only available on Unix platforms.
187+
if is_windows:
188+
return
189+
try:
190+
return os.ttyname(sys.stdin.fileno())
191+
except (OSError, io.UnsupportedOperation):
192+
# Standard input was redirected to a pseudofile.
193+
# This can happen when running tests on IDEs or
194+
# running scripts with redirected input.
195+
return
196+
197+
@cached_property
198+
def _hostname(self):
199+
return socket.gethostname()
200+
201+
@cached_property
202+
def _timestamp(self):
203+
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
204+
205+
def _sweep_cache(self):
206+
t = threading.Thread(
207+
target=self._sweeper.sweep,
208+
args=(self._timestamp - _SESSION_LENGTH_SECONDS,),
209+
daemon=True,
210+
)
211+
t.start()
212+
213+
214+
def _get_cli_session_orchestrator():
215+
conn = CLISessionDatabaseConnection()
216+
return CLISessionOrchestrator(
217+
CLISessionGenerator(),
218+
CLISessionDatabaseWriter(conn),
219+
CLISessionDatabaseReader(conn),
220+
CLISessionDatabaseSweeper(conn),
221+
)
222+
223+
224+
def add_session_id_component_to_user_agent_extra(session):
225+
cli_session_orchestrator = _get_cli_session_orchestrator()
226+
add_component_to_user_agent_extra(
227+
session, UserAgentComponent("sid", cli_session_orchestrator.session_id)
228+
)

tests/unit/test_clidriver.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,12 @@ def _run_main(self, args, parsed_globals):
274274
return 0
275275

276276

277+
class FakeCLISessionOrchestrator:
278+
@property
279+
def session_id(self):
280+
return 'mysessionid'
281+
282+
277283
class TestCliDriver:
278284
def setup_method(self):
279285
self.session = FakeSession()
@@ -774,13 +780,19 @@ def test_idempotency_token_is_not_required_in_help_text(self):
774780
self.assertEqual(rc, 252)
775781
self.assertNotIn('--idempotency-token', self.stderr.getvalue())
776782

783+
@mock.patch(
784+
'awscli.telemetry._get_cli_session_orchestrator',
785+
return_value=FakeCLISessionOrchestrator(),
786+
)
777787
@mock.patch('awscli.clidriver.platform.system', return_value='Linux')
778788
@mock.patch('awscli.clidriver.platform.machine', return_value='x86_64')
779789
@mock.patch('awscli.clidriver.distro.id', return_value='amzn')
780790
@mock.patch('awscli.clidriver.distro.major_version', return_value='1')
781791
def test_user_agent_for_linux(self, *args):
782792
driver = create_clidriver()
783-
expected_user_agent = 'md/installer#source md/distrib#amzn.1'
793+
expected_user_agent = (
794+
'md/installer#source md/distrib#amzn.1 sid/mysessionid'
795+
)
784796
self.assertEqual(expected_user_agent, driver.session.user_agent_extra)
785797

786798
def test_user_agent(self, *args):

0 commit comments

Comments
 (0)