playground-ls-cli 4.14.1.dev8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localstack_cli/__init__.py +0 -0
- localstack_cli/cli/__init__.py +10 -0
- localstack_cli/cli/console.py +11 -0
- localstack_cli/cli/core_plugin.py +12 -0
- localstack_cli/cli/exceptions.py +19 -0
- localstack_cli/cli/localstack.py +951 -0
- localstack_cli/cli/lpm.py +138 -0
- localstack_cli/cli/main.py +22 -0
- localstack_cli/cli/plugin.py +39 -0
- localstack_cli/cli/plugins.py +134 -0
- localstack_cli/cli/profiles.py +65 -0
- localstack_cli/config.py +1689 -0
- localstack_cli/constants.py +165 -0
- localstack_cli/logging/__init__.py +0 -0
- localstack_cli/logging/format.py +194 -0
- localstack_cli/logging/setup.py +142 -0
- localstack_cli/packages/__init__.py +25 -0
- localstack_cli/packages/api.py +418 -0
- localstack_cli/packages/core.py +416 -0
- localstack_cli/pro/__init__.py +0 -0
- localstack_cli/pro/core/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/__init__.py +1 -0
- localstack_cli/pro/core/bootstrap/auth.py +213 -0
- localstack_cli/pro/core/bootstrap/dns_utils.py +55 -0
- localstack_cli/pro/core/bootstrap/entitlements.py +117 -0
- localstack_cli/pro/core/bootstrap/extensions/__init__.py +3 -0
- localstack_cli/pro/core/bootstrap/extensions/__main__.py +106 -0
- localstack_cli/pro/core/bootstrap/extensions/autoinstall.py +63 -0
- localstack_cli/pro/core/bootstrap/extensions/bootstrap.py +97 -0
- localstack_cli/pro/core/bootstrap/extensions/repository.py +374 -0
- localstack_cli/pro/core/bootstrap/licensingv2.py +1259 -0
- localstack_cli/pro/core/bootstrap/pods/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/pods/api_types.py +17 -0
- localstack_cli/pro/core/bootstrap/pods/constants.py +26 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/api.py +75 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/configs.py +69 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/params.py +86 -0
- localstack_cli/pro/core/bootstrap/pods_client.py +834 -0
- localstack_cli/pro/core/cli/__init__.py +0 -0
- localstack_cli/pro/core/cli/auth.py +226 -0
- localstack_cli/pro/core/cli/aws.py +16 -0
- localstack_cli/pro/core/cli/cli.py +99 -0
- localstack_cli/pro/core/cli/click_utils.py +21 -0
- localstack_cli/pro/core/cli/cloud_pods.py +465 -0
- localstack_cli/pro/core/cli/diff_view.py +41 -0
- localstack_cli/pro/core/cli/ephemeral.py +199 -0
- localstack_cli/pro/core/cli/extensions.py +492 -0
- localstack_cli/pro/core/cli/iam.py +180 -0
- localstack_cli/pro/core/cli/license.py +90 -0
- localstack_cli/pro/core/cli/localstack.py +118 -0
- localstack_cli/pro/core/cli/replicator.py +378 -0
- localstack_cli/pro/core/cli/state.py +183 -0
- localstack_cli/pro/core/cli/tree_view.py +235 -0
- localstack_cli/pro/core/config.py +556 -0
- localstack_cli/pro/core/constants.py +54 -0
- localstack_cli/pro/core/plugins.py +169 -0
- localstack_cli/runtime/__init__.py +6 -0
- localstack_cli/runtime/exceptions.py +7 -0
- localstack_cli/runtime/hooks.py +73 -0
- localstack_cli/testing/__init__.py +1 -0
- localstack_cli/testing/config.py +4 -0
- localstack_cli/utils/__init__.py +0 -0
- localstack_cli/utils/analytics/__init__.py +12 -0
- localstack_cli/utils/analytics/cli.py +67 -0
- localstack_cli/utils/analytics/client.py +111 -0
- localstack_cli/utils/analytics/events.py +30 -0
- localstack_cli/utils/analytics/logger.py +48 -0
- localstack_cli/utils/analytics/metadata.py +250 -0
- localstack_cli/utils/analytics/publisher.py +160 -0
- localstack_cli/utils/analytics/service_request_aggregator.py +133 -0
- localstack_cli/utils/archives.py +271 -0
- localstack_cli/utils/batching.py +258 -0
- localstack_cli/utils/bootstrap.py +1418 -0
- localstack_cli/utils/checksum.py +313 -0
- localstack_cli/utils/collections.py +554 -0
- localstack_cli/utils/common.py +229 -0
- localstack_cli/utils/container_networking.py +142 -0
- localstack_cli/utils/container_utils/__init__.py +0 -0
- localstack_cli/utils/container_utils/container_client.py +1585 -0
- localstack_cli/utils/container_utils/docker_cmd_client.py +987 -0
- localstack_cli/utils/container_utils/docker_sdk_client.py +1018 -0
- localstack_cli/utils/crypto.py +294 -0
- localstack_cli/utils/docker_utils.py +272 -0
- localstack_cli/utils/files.py +327 -0
- localstack_cli/utils/functions.py +92 -0
- localstack_cli/utils/http.py +326 -0
- localstack_cli/utils/json.py +219 -0
- localstack_cli/utils/net.py +516 -0
- localstack_cli/utils/no_exit_argument_parser.py +19 -0
- localstack_cli/utils/numbers.py +49 -0
- localstack_cli/utils/objects.py +235 -0
- localstack_cli/utils/patch.py +260 -0
- localstack_cli/utils/platform.py +77 -0
- localstack_cli/utils/run.py +514 -0
- localstack_cli/utils/server/__init__.py +0 -0
- localstack_cli/utils/server/tcp_proxy.py +108 -0
- localstack_cli/utils/serving.py +187 -0
- localstack_cli/utils/ssl.py +71 -0
- localstack_cli/utils/strings.py +245 -0
- localstack_cli/utils/sync.py +267 -0
- localstack_cli/utils/threads.py +163 -0
- localstack_cli/utils/time.py +81 -0
- localstack_cli/utils/urls.py +21 -0
- localstack_cli/utils/venv.py +100 -0
- localstack_cli/utils/xml.py +41 -0
- localstack_cli/version.py +34 -0
- playground_ls_cli-4.14.1.dev8.dist-info/METADATA +95 -0
- playground_ls_cli-4.14.1.dev8.dist-info/RECORD +112 -0
- playground_ls_cli-4.14.1.dev8.dist-info/WHEEL +5 -0
- playground_ls_cli-4.14.1.dev8.dist-info/entry_points.txt +17 -0
- playground_ls_cli-4.14.1.dev8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
|
|
6
|
+
from localstack_cli import config
|
|
7
|
+
from localstack_cli.constants import VERSION
|
|
8
|
+
from localstack_cli.runtime import get_current_runtime, hooks
|
|
9
|
+
from localstack_cli.utils.bootstrap import Container
|
|
10
|
+
from localstack_cli.utils.files import rm_rf
|
|
11
|
+
from localstack_cli.utils.functions import call_safe
|
|
12
|
+
from localstack_cli.utils.json import FileMappedDocument
|
|
13
|
+
from localstack_cli.utils.objects import singleton_factory
|
|
14
|
+
from localstack_cli.utils.strings import long_uid, md5
|
|
15
|
+
|
|
16
|
+
LOG = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_PHYSICAL_ID_SALT = "ls"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclasses.dataclass
|
|
22
|
+
class ClientMetadata:
|
|
23
|
+
session_id: str
|
|
24
|
+
machine_id: str
|
|
25
|
+
api_key: str
|
|
26
|
+
system: str
|
|
27
|
+
version: str
|
|
28
|
+
is_ci: bool
|
|
29
|
+
is_docker: bool
|
|
30
|
+
is_testing: bool
|
|
31
|
+
product: str
|
|
32
|
+
edition: str
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
d = dataclasses.asdict(self)
|
|
36
|
+
|
|
37
|
+
# anonymize api_key
|
|
38
|
+
k = d.get("api_key")
|
|
39
|
+
if k:
|
|
40
|
+
k = "*" * len(k)
|
|
41
|
+
d["api_key"] = k
|
|
42
|
+
|
|
43
|
+
return f"ClientMetadata({d})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_version_string() -> str:
|
|
47
|
+
gh = config.LOCALSTACK_BUILD_GIT_HASH
|
|
48
|
+
if gh:
|
|
49
|
+
return f"{VERSION}:{gh}"
|
|
50
|
+
else:
|
|
51
|
+
return VERSION
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_client_metadata() -> ClientMetadata:
|
|
55
|
+
return ClientMetadata(
|
|
56
|
+
session_id=get_session_id(),
|
|
57
|
+
machine_id=get_machine_id(),
|
|
58
|
+
api_key=get_api_key_or_auth_token() or "", # api key should not be None
|
|
59
|
+
system=get_system(),
|
|
60
|
+
version=get_version_string(),
|
|
61
|
+
is_ci=os.getenv("CI") is not None,
|
|
62
|
+
is_docker=config.is_in_docker,
|
|
63
|
+
is_testing=config.is_local_test_mode(),
|
|
64
|
+
product=get_localstack_product(),
|
|
65
|
+
edition=os.getenv("LOCALSTACK_TELEMETRY_EDITION") or get_localstack_edition(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@singleton_factory
|
|
70
|
+
def get_session_id() -> str:
|
|
71
|
+
"""
|
|
72
|
+
Returns the unique ID for this LocalStack session.
|
|
73
|
+
:return: a UUID
|
|
74
|
+
"""
|
|
75
|
+
return _generate_session_id()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@singleton_factory
|
|
79
|
+
def get_client_metadata() -> ClientMetadata:
|
|
80
|
+
metadata = read_client_metadata()
|
|
81
|
+
|
|
82
|
+
if config.DEBUG_ANALYTICS:
|
|
83
|
+
LOG.info("resolved client metadata: %s", metadata)
|
|
84
|
+
|
|
85
|
+
return metadata
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@singleton_factory
|
|
89
|
+
def get_machine_id() -> str:
|
|
90
|
+
cache_path = os.path.join(config.dirs.cache, "machine.json")
|
|
91
|
+
try:
|
|
92
|
+
doc = FileMappedDocument(cache_path)
|
|
93
|
+
except Exception:
|
|
94
|
+
# it's possible that the file is somehow messed up, so we try to delete the file first and try again.
|
|
95
|
+
# if that fails, we return a generated ID.
|
|
96
|
+
call_safe(rm_rf, args=(cache_path,))
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
doc = FileMappedDocument(cache_path)
|
|
100
|
+
except Exception:
|
|
101
|
+
return _generate_machine_id()
|
|
102
|
+
|
|
103
|
+
if "machine_id" not in doc:
|
|
104
|
+
# generate a machine id
|
|
105
|
+
doc["machine_id"] = _generate_machine_id()
|
|
106
|
+
# try to cache the machine ID
|
|
107
|
+
call_safe(doc.save)
|
|
108
|
+
|
|
109
|
+
return doc["machine_id"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_localstack_edition() -> str:
|
|
113
|
+
# Generator expression to find the first hidden file ending with '-version'
|
|
114
|
+
version_file = next(
|
|
115
|
+
(
|
|
116
|
+
f
|
|
117
|
+
for f in os.listdir(config.dirs.static_libs)
|
|
118
|
+
if f.startswith(".") and f.endswith("-version")
|
|
119
|
+
),
|
|
120
|
+
None,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Return the base name of the version file, or unknown if no file is found
|
|
124
|
+
return version_file.removesuffix("-version").removeprefix(".") if version_file else "unknown"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_localstack_product() -> str:
|
|
128
|
+
"""
|
|
129
|
+
Returns the telemetry product name from the env var, runtime, or "unknown".
|
|
130
|
+
"""
|
|
131
|
+
runtime_product = None
|
|
132
|
+
if get_current_runtime is not None:
|
|
133
|
+
try:
|
|
134
|
+
runtime_product = get_current_runtime().components.name
|
|
135
|
+
except (ValueError, AttributeError):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
return os.getenv("LOCALSTACK_TELEMETRY_PRODUCT") or runtime_product or "unknown"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_license_activated() -> bool:
|
|
142
|
+
try:
|
|
143
|
+
from localstack_cli.pro.core import config # noqa
|
|
144
|
+
except ImportError:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
from localstack_cli.pro.core.bootstrap import licensingv2
|
|
149
|
+
|
|
150
|
+
return licensingv2.get_licensed_environment().activated
|
|
151
|
+
except Exception:
|
|
152
|
+
LOG.error(
|
|
153
|
+
"Could not determine license activation status",
|
|
154
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
|
155
|
+
)
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _generate_session_id() -> str:
|
|
160
|
+
return long_uid()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _anonymize_physical_id(physical_id: str) -> str:
|
|
164
|
+
"""
|
|
165
|
+
Returns 12 digits of the salted hash of the given physical ID.
|
|
166
|
+
|
|
167
|
+
:param physical_id: the physical id
|
|
168
|
+
:return: an anonymized 12 digit value representing the physical ID.
|
|
169
|
+
"""
|
|
170
|
+
hashed = md5(_PHYSICAL_ID_SALT + physical_id)
|
|
171
|
+
return hashed[:12]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _generate_machine_id() -> str:
|
|
175
|
+
try:
|
|
176
|
+
# try to get a robust ID from the docker socket (which will be the same from the host and the
|
|
177
|
+
# container)
|
|
178
|
+
from localstack_cli.utils.docker_utils import DOCKER_CLIENT
|
|
179
|
+
|
|
180
|
+
docker_id = DOCKER_CLIENT.get_system_id()
|
|
181
|
+
# some systems like podman don't return a stable ID, so we double-check that here
|
|
182
|
+
if docker_id == DOCKER_CLIENT.get_system_id():
|
|
183
|
+
return f"dkr_{_anonymize_physical_id(docker_id)}"
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
if config.is_in_docker:
|
|
188
|
+
return f"gen_{long_uid()[:12]}"
|
|
189
|
+
|
|
190
|
+
# this can potentially be useful when generated on the host using the CLI and then mounted into the
|
|
191
|
+
# container via machine.json
|
|
192
|
+
try:
|
|
193
|
+
if os.path.exists("/etc/machine-id"):
|
|
194
|
+
with open("/etc/machine-id") as fd:
|
|
195
|
+
machine_id = str(fd.read()).strip()
|
|
196
|
+
if machine_id:
|
|
197
|
+
return f"sys_{_anonymize_physical_id(machine_id)}"
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
# always fall back to a generated id
|
|
202
|
+
return f"gen_{long_uid()[:12]}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_api_key_or_auth_token() -> str | None:
|
|
206
|
+
# TODO: this is duplicated code from ext, but should probably migrate that to localstack
|
|
207
|
+
auth_token = os.environ.get("LOCALSTACK_AUTH_TOKEN", "").strip("'\" ")
|
|
208
|
+
if auth_token:
|
|
209
|
+
return auth_token
|
|
210
|
+
|
|
211
|
+
api_key = os.environ.get("LOCALSTACK_API_KEY", "").strip("'\" ")
|
|
212
|
+
if api_key:
|
|
213
|
+
return api_key
|
|
214
|
+
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@singleton_factory
|
|
219
|
+
def get_system() -> str:
|
|
220
|
+
try:
|
|
221
|
+
# try to get the system from the docker socket
|
|
222
|
+
from localstack_cli.utils.docker_utils import DOCKER_CLIENT
|
|
223
|
+
|
|
224
|
+
system = DOCKER_CLIENT.get_system_info()
|
|
225
|
+
if system.get("OSType"):
|
|
226
|
+
return system.get("OSType").lower()
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
if config.is_in_docker:
|
|
231
|
+
return "docker"
|
|
232
|
+
|
|
233
|
+
return platform.system().lower()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@hooks.prepare_host()
|
|
237
|
+
def publish_invocation_metadata():
|
|
238
|
+
"""Lazy-init machine ID into cache on the host, which can then be used in the container."""
|
|
239
|
+
get_machine_id()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@hooks.configure_localstack_container()
|
|
243
|
+
def set_analytics_header(container: Container):
|
|
244
|
+
"""Mount the machine file from the host's CLI cache directory into the container."""
|
|
245
|
+
from localstack_cli.utils.container_utils.container_client import BindMount
|
|
246
|
+
|
|
247
|
+
machine_file = os.path.join(config.dirs.cache, "machine.json")
|
|
248
|
+
if os.path.isfile(machine_file):
|
|
249
|
+
target = os.path.join(config.dirs.for_container().cache, "machine.json")
|
|
250
|
+
container.config.volumes.add(BindMount(machine_file, target, read_only=True))
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import atexit
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from localstack_cli import config
|
|
7
|
+
from localstack_cli.utils.batching import AsyncBatcher
|
|
8
|
+
from localstack_cli.utils.threads import FuncThread, start_thread, start_worker_thread
|
|
9
|
+
|
|
10
|
+
from .client import AnalyticsClient
|
|
11
|
+
from .events import Event, EventHandler
|
|
12
|
+
from .metadata import get_client_metadata
|
|
13
|
+
|
|
14
|
+
LOG = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Publisher(abc.ABC):
|
|
18
|
+
"""
|
|
19
|
+
A publisher takes a batch of events and publishes them to a destination.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def publish(self, events: list[Event]):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def close(self):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AnalyticsClientPublisher(Publisher):
|
|
30
|
+
client: AnalyticsClient
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: AnalyticsClient = None) -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.client = client or AnalyticsClient()
|
|
35
|
+
|
|
36
|
+
def publish(self, events: list[Event]):
|
|
37
|
+
self.client.append_events(events)
|
|
38
|
+
|
|
39
|
+
def close(self):
|
|
40
|
+
self.client.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Printer(Publisher):
|
|
44
|
+
"""
|
|
45
|
+
Publisher that prints serialized events to stdout.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def publish(self, events: list[Event]):
|
|
49
|
+
for event in events:
|
|
50
|
+
print(event.asdict())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GlobalAnalyticsBus(EventHandler):
|
|
54
|
+
_batcher: AsyncBatcher[Event]
|
|
55
|
+
_client: AnalyticsClient
|
|
56
|
+
_worker_thread: FuncThread | None
|
|
57
|
+
|
|
58
|
+
def __init__(self, client: AnalyticsClient = None, flush_size=20, flush_interval=10) -> None:
|
|
59
|
+
self._client = client or AnalyticsClient()
|
|
60
|
+
self._publisher = AnalyticsClientPublisher(self._client)
|
|
61
|
+
self._batcher = AsyncBatcher(
|
|
62
|
+
self._handle_batch,
|
|
63
|
+
max_batch_size=flush_size,
|
|
64
|
+
max_flush_interval=flush_interval,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self._started = False
|
|
68
|
+
self._startup_mutex = threading.Lock()
|
|
69
|
+
self._worker_thread = None
|
|
70
|
+
|
|
71
|
+
self.force_tracking = False # allow class to ignore all other tracking config
|
|
72
|
+
self.tracking_disabled = False # disables tracking if global config would otherwise track
|
|
73
|
+
|
|
74
|
+
def _handle_batch(self, batch: list[Event]):
|
|
75
|
+
"""Method that satisfies the BatchHandler[Event] protocol and is passed to AsyncBatcher."""
|
|
76
|
+
try:
|
|
77
|
+
self._publisher.publish(batch)
|
|
78
|
+
except Exception:
|
|
79
|
+
# currently we're just dropping events if something goes wrong during publishing
|
|
80
|
+
if config.DEBUG_ANALYTICS:
|
|
81
|
+
LOG.exception("error while publishing analytics events")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_tracking_disabled(self):
|
|
85
|
+
if self.force_tracking:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# don't track if event tracking is disabled globally
|
|
89
|
+
if config.DISABLE_EVENTS:
|
|
90
|
+
return True
|
|
91
|
+
# don't track for internal test runs (like integration tests)
|
|
92
|
+
if config.is_local_test_mode():
|
|
93
|
+
return True
|
|
94
|
+
if self.tracking_disabled:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def handle(self, event: Event):
|
|
100
|
+
"""
|
|
101
|
+
Publish an event to the global analytics event publisher.
|
|
102
|
+
"""
|
|
103
|
+
if self.is_tracking_disabled:
|
|
104
|
+
if config.DEBUG_ANALYTICS:
|
|
105
|
+
LOG.debug("tracking disabled, skipping event %s", event)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if not self._started:
|
|
109
|
+
# we make sure the batching worker is started
|
|
110
|
+
self._start()
|
|
111
|
+
|
|
112
|
+
self._batcher.add(event)
|
|
113
|
+
|
|
114
|
+
def _start(self):
|
|
115
|
+
with self._startup_mutex:
|
|
116
|
+
if self._started:
|
|
117
|
+
return
|
|
118
|
+
self._started = True
|
|
119
|
+
|
|
120
|
+
# startup has to run async, otherwise first call to handle() could block a long time.
|
|
121
|
+
start_worker_thread(self._do_start_retry)
|
|
122
|
+
|
|
123
|
+
def _do_start_retry(self, *_):
|
|
124
|
+
# TODO: actually retry
|
|
125
|
+
try:
|
|
126
|
+
if config.DEBUG_ANALYTICS:
|
|
127
|
+
LOG.debug("trying to register session with analytics backend")
|
|
128
|
+
response = self._client.start_session(get_client_metadata())
|
|
129
|
+
if config.DEBUG_ANALYTICS:
|
|
130
|
+
LOG.debug("session endpoint returned: %s", response)
|
|
131
|
+
|
|
132
|
+
if not response.track_events():
|
|
133
|
+
if config.DEBUG_ANALYTICS:
|
|
134
|
+
LOG.debug("gracefully disabling analytics tracking")
|
|
135
|
+
self.tracking_disabled = True
|
|
136
|
+
|
|
137
|
+
except Exception:
|
|
138
|
+
self.tracking_disabled = True
|
|
139
|
+
if config.DEBUG_ANALYTICS:
|
|
140
|
+
LOG.exception("error while registering session. disabling tracking")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self._worker_thread = start_thread(self._run, name="global-analytics-bus")
|
|
144
|
+
|
|
145
|
+
# given the "Global" nature of this class, we register a global atexit hook to make sure all events are flushed
|
|
146
|
+
# when localstack shuts down.
|
|
147
|
+
def _do_close():
|
|
148
|
+
self.close_sync(timeout=2)
|
|
149
|
+
|
|
150
|
+
atexit.register(_do_close)
|
|
151
|
+
|
|
152
|
+
def _run(self, *_):
|
|
153
|
+
# main control loop, simply runs the batcher
|
|
154
|
+
self._batcher.run()
|
|
155
|
+
|
|
156
|
+
def close_sync(self, timeout=None):
|
|
157
|
+
self._batcher.close()
|
|
158
|
+
|
|
159
|
+
if self._worker_thread:
|
|
160
|
+
self._worker_thread.join(timeout=timeout)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from localstack_cli import config
|
|
8
|
+
try:
|
|
9
|
+
from localstack_cli.runtime.shutdown import SHUTDOWN_HANDLERS
|
|
10
|
+
except ImportError:
|
|
11
|
+
SHUTDOWN_HANDLERS = None
|
|
12
|
+
from localstack_cli.utils import analytics
|
|
13
|
+
try:
|
|
14
|
+
from localstack_cli.utils.scheduler import Scheduler
|
|
15
|
+
except ImportError:
|
|
16
|
+
Scheduler = None
|
|
17
|
+
|
|
18
|
+
LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
DEFAULT_FLUSH_INTERVAL_SECS = 15
|
|
21
|
+
EVENT_NAME = "aws_request_agg"
|
|
22
|
+
OPTIONAL_FIELDS = ["err_type"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ServiceRequestInfo(NamedTuple):
|
|
26
|
+
service: str
|
|
27
|
+
operation: str
|
|
28
|
+
status_code: int
|
|
29
|
+
err_type: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ServiceRequestAggregator:
|
|
33
|
+
"""
|
|
34
|
+
Collects API call data, aggregates it into small batches, and periodically emits (flushes) it as an
|
|
35
|
+
analytics event.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, flush_interval: float = DEFAULT_FLUSH_INTERVAL_SECS):
|
|
39
|
+
self.counter = Counter()
|
|
40
|
+
self._flush_interval = flush_interval
|
|
41
|
+
self._flush_scheduler = Scheduler()
|
|
42
|
+
self._mutex = threading.RLock()
|
|
43
|
+
self._period_start_time = datetime.datetime.now(datetime.UTC)
|
|
44
|
+
self._is_started = False
|
|
45
|
+
self._is_shutdown = False
|
|
46
|
+
|
|
47
|
+
def start(self):
|
|
48
|
+
"""
|
|
49
|
+
Start a thread that periodically flushes HTTP response data aggregations as analytics events
|
|
50
|
+
:returns: the thread containing the running flush scheduler
|
|
51
|
+
"""
|
|
52
|
+
with self._mutex:
|
|
53
|
+
if self._is_started:
|
|
54
|
+
return
|
|
55
|
+
self._is_started = True
|
|
56
|
+
|
|
57
|
+
# schedule flush task
|
|
58
|
+
self._flush_scheduler.schedule(
|
|
59
|
+
func=self._flush, period=self._flush_interval, fixed_rate=True
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# start thread
|
|
63
|
+
_flush_scheduler_thread = threading.Thread(
|
|
64
|
+
target=self._flush_scheduler.run, daemon=True
|
|
65
|
+
)
|
|
66
|
+
_flush_scheduler_thread.start()
|
|
67
|
+
|
|
68
|
+
SHUTDOWN_HANDLERS.register(self.shutdown)
|
|
69
|
+
|
|
70
|
+
def shutdown(self):
|
|
71
|
+
with self._mutex:
|
|
72
|
+
if not self._is_started:
|
|
73
|
+
return
|
|
74
|
+
if self._is_shutdown:
|
|
75
|
+
return
|
|
76
|
+
self._is_shutdown = True
|
|
77
|
+
|
|
78
|
+
self._flush()
|
|
79
|
+
self._flush_scheduler.close()
|
|
80
|
+
SHUTDOWN_HANDLERS.unregister(self.shutdown)
|
|
81
|
+
|
|
82
|
+
def add_request(self, request_info: ServiceRequestInfo):
|
|
83
|
+
"""
|
|
84
|
+
Add an API call for aggregation and collection.
|
|
85
|
+
|
|
86
|
+
:param request_info: information about the API call.
|
|
87
|
+
"""
|
|
88
|
+
if config.DISABLE_EVENTS:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if self._is_shutdown:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
with self._mutex:
|
|
95
|
+
self.counter[request_info] += 1
|
|
96
|
+
|
|
97
|
+
def _flush(self):
|
|
98
|
+
"""
|
|
99
|
+
Flushes the current batch of HTTP response data as an analytics event.
|
|
100
|
+
This happens automatically in the background.
|
|
101
|
+
"""
|
|
102
|
+
with self._mutex:
|
|
103
|
+
try:
|
|
104
|
+
if len(self.counter) == 0:
|
|
105
|
+
return
|
|
106
|
+
analytics_payload = self._create_analytics_payload()
|
|
107
|
+
self._emit_payload(analytics_payload)
|
|
108
|
+
self.counter.clear()
|
|
109
|
+
finally:
|
|
110
|
+
self._period_start_time = datetime.datetime.now(datetime.UTC)
|
|
111
|
+
|
|
112
|
+
def _create_analytics_payload(self):
|
|
113
|
+
return {
|
|
114
|
+
"period_start_time": self._period_start_time.isoformat().replace("+00:00", "Z"),
|
|
115
|
+
"period_end_time": datetime.datetime.now(datetime.UTC)
|
|
116
|
+
.isoformat()
|
|
117
|
+
.replace("+00:00", "Z"),
|
|
118
|
+
"api_calls": self._aggregate_api_calls(self.counter),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def _aggregate_api_calls(self, counter) -> list:
|
|
122
|
+
aggregations = []
|
|
123
|
+
for api_call_info, count in counter.items():
|
|
124
|
+
doc = api_call_info._asdict()
|
|
125
|
+
for field in OPTIONAL_FIELDS:
|
|
126
|
+
if doc.get(field) is None:
|
|
127
|
+
del doc[field]
|
|
128
|
+
doc["count"] = count
|
|
129
|
+
aggregations.append(doc)
|
|
130
|
+
return aggregations
|
|
131
|
+
|
|
132
|
+
def _emit_payload(self, analytics_payload: dict):
|
|
133
|
+
analytics.log.event(EVENT_NAME, analytics_payload)
|