Skip to content

Commit 82bdf95

Browse files
committed
Add session id to user agent string
1 parent 03c7ab8 commit 82bdf95

File tree

9 files changed

+560
-12
lines changed

9 files changed

+560
-12
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "enhancement",
3+
"category": "User Agent",
4+
"description": "Append session id to user agent string"
5+
}

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: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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 hashlib
14+
import io
15+
import os
16+
import socket
17+
import sqlite3
18+
import sys
19+
import threading
20+
import time
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.home() / '.aws' / 'cli' / 'cache'
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, connection=None):
55+
self._connection = connection or 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 cache_key(self):
170+
return self._generator.generate_cache_key(self._hostname, self._tty)
171+
172+
@cached_property
173+
def _session_id(self):
174+
return self._generator.generate_session_id(
175+
self._hostname, self._tty, self._timestamp
176+
)
177+
178+
@cached_property
179+
def session_id(self):
180+
if (cached_data := self._reader.read(self.cache_key)) is not None:
181+
# Cache hit, but session id is expired. Generate new id and update.
182+
if (
183+
cached_data.timestamp + _SESSION_LENGTH_SECONDS
184+
< self._timestamp
185+
):
186+
cached_data.session_id = self._session_id
187+
# Always update the timestamp to last used.
188+
cached_data.timestamp = self._timestamp
189+
self._writer.write(cached_data)
190+
return cached_data.session_id
191+
# Cache miss, generate and write new record.
192+
session_id = self._session_id
193+
session_data = CLISessionData(
194+
self.cache_key, session_id, self._timestamp
195+
)
196+
self._writer.write(session_data)
197+
return session_id
198+
199+
@cached_property
200+
def _tty(self):
201+
# os.ttyname is only available on Unix platforms.
202+
if is_windows:
203+
return
204+
try:
205+
return os.ttyname(sys.stdin.fileno())
206+
except (OSError, io.UnsupportedOperation):
207+
# Standard input was redirected to a pseudofile.
208+
# This can happen when running tests on IDEs or
209+
# running scripts with redirected input.
210+
return
211+
212+
@cached_property
213+
def _hostname(self):
214+
return socket.gethostname()
215+
216+
@cached_property
217+
def _timestamp(self):
218+
return int(time.time())
219+
220+
def _sweep_cache(self):
221+
t = threading.Thread(
222+
target=self._sweeper.sweep,
223+
args=(self._timestamp - _SESSION_LENGTH_SECONDS,),
224+
daemon=True,
225+
)
226+
t.start()
227+
228+
229+
def _get_cli_session_orchestrator():
230+
conn = CLISessionDatabaseConnection()
231+
return CLISessionOrchestrator(
232+
CLISessionGenerator(),
233+
CLISessionDatabaseWriter(conn),
234+
CLISessionDatabaseReader(conn),
235+
CLISessionDatabaseSweeper(conn),
236+
)
237+
238+
239+
def add_session_id_component_to_user_agent_extra(session, orchestrator=None):
240+
cli_session_orchestrator = orchestrator or _get_cli_session_orchestrator()
241+
add_component_to_user_agent_extra(
242+
session, UserAgentComponent("sid", cli_session_orchestrator.session_id)
243+
)

tests/backends/build_system/functional/test_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
parse_requirements,
2626
)
2727

28-
from tests.backends.build_system.markers import if_windows, skip_if_windows
28+
from tests.markers import if_windows, skip_if_windows
2929

3030

3131
@pytest.fixture

tests/backends/build_system/unit/test_install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from build_system.install import Installer, Uninstaller
77

88
from backends.build_system.utils import Utils
9-
from tests.backends.build_system.markers import if_windows, skip_if_windows
9+
from tests.markers import if_windows, skip_if_windows
1010

1111

1212
class FakeUtils(Utils):

0 commit comments

Comments
 (0)