localstack-core 4.5.1.dev21__py3-none-any.whl → 4.5.1.dev23__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/services/apigateway/analytics.py +2 -2
- localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py +3 -3
- localstack/services/cloudformation/{usage.py → analytics.py} +2 -2
- localstack/services/cloudformation/engine/v2/change_set_model_executor.py +25 -18
- localstack/services/cloudformation/resource_provider.py +3 -3
- localstack/services/cloudformation/v2/entities.py +3 -0
- localstack/services/cloudformation/v2/provider.py +44 -8
- localstack/services/events/analytics.py +4 -2
- localstack/services/kinesis/resource_providers/aws_kinesis_stream.py +1 -1
- localstack/services/lambda_/analytics.py +4 -4
- localstack/services/sns/analytics.py +4 -2
- localstack/services/stepfunctions/{usage.py → analytics.py} +2 -2
- localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py +2 -2
- localstack/utils/analytics/metrics/__init__.py +6 -0
- localstack/utils/analytics/metrics/api.py +42 -0
- localstack/utils/analytics/metrics/counter.py +209 -0
- localstack/utils/analytics/metrics/publisher.py +36 -0
- localstack/utils/analytics/metrics/registry.py +97 -0
- localstack/version.py +2 -2
- {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/METADATA +1 -1
- {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/RECORD +29 -25
- {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/entry_points.txt +1 -1
- localstack_core-4.5.1.dev23.dist-info/plux.json +1 -0
- localstack/utils/analytics/metrics.py +0 -373
- localstack_core-4.5.1.dev21.dist-info/plux.json +0 -1
- {localstack_core-4.5.1.dev21.data → localstack_core-4.5.1.dev23.data}/scripts/localstack +0 -0
- {localstack_core-4.5.1.dev21.data → localstack_core-4.5.1.dev23.data}/scripts/localstack-supervisor +0 -0
- {localstack_core-4.5.1.dev21.data → localstack_core-4.5.1.dev23.data}/scripts/localstack.bat +0 -0
- {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/WHEEL +0 -0
- {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/licenses/LICENSE.txt +0 -0
- {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,5 @@
|
|
1
|
-
from localstack.utils.analytics.metrics import
|
1
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
2
2
|
|
3
|
-
invocation_counter =
|
3
|
+
invocation_counter = LabeledCounter(
|
4
4
|
namespace="apigateway", name="rest_api_execute", labels=["invocation_type"]
|
5
5
|
)
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import logging
|
2
2
|
|
3
3
|
from localstack.http import Response
|
4
|
-
from localstack.utils.analytics.metrics import
|
4
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
5
5
|
|
6
6
|
from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain
|
7
7
|
from ..context import RestApiInvocationContext
|
@@ -10,9 +10,9 @@ LOG = logging.getLogger(__name__)
|
|
10
10
|
|
11
11
|
|
12
12
|
class IntegrationUsageCounter(RestApiGatewayHandler):
|
13
|
-
counter:
|
13
|
+
counter: LabeledCounter
|
14
14
|
|
15
|
-
def __init__(self, counter:
|
15
|
+
def __init__(self, counter: LabeledCounter):
|
16
16
|
self.counter = counter
|
17
17
|
|
18
18
|
def __call__(
|
@@ -1,7 +1,7 @@
|
|
1
|
-
from localstack.utils.analytics.metrics import
|
1
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
2
2
|
|
3
3
|
COUNTER_NAMESPACE = "cloudformation"
|
4
4
|
|
5
|
-
resources =
|
5
|
+
resources = LabeledCounter(
|
6
6
|
namespace=COUNTER_NAMESPACE, name="resources", labels=["resource_type", "missing"]
|
7
7
|
)
|
@@ -115,7 +115,7 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
115
115
|
node_resource = self._get_node_resource_for(
|
116
116
|
resource_name=depends_on_resource_logical_id, node_template=self._node_template
|
117
117
|
)
|
118
|
-
self.
|
118
|
+
self.visit(node_resource)
|
119
119
|
|
120
120
|
return array_identifiers_delta
|
121
121
|
|
@@ -257,6 +257,7 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
257
257
|
resource_provider = resource_provider_executor.try_load_resource_provider(resource_type)
|
258
258
|
|
259
259
|
extra_resource_properties = {}
|
260
|
+
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
|
260
261
|
if resource_provider is not None:
|
261
262
|
# TODO: stack events
|
262
263
|
try:
|
@@ -271,11 +272,15 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
271
272
|
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
272
273
|
)
|
273
274
|
stack = self._change_set.stack
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
275
|
+
match stack.status:
|
276
|
+
case StackStatus.CREATE_IN_PROGRESS:
|
277
|
+
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
|
278
|
+
case StackStatus.UPDATE_IN_PROGRESS:
|
279
|
+
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
|
280
|
+
case StackStatus.DELETE_IN_PROGRESS:
|
281
|
+
stack.set_stack_status(StackStatus.DELETE_FAILED, reason=reason)
|
282
|
+
case _:
|
283
|
+
raise NotImplementedError(f"Unexpected stack status: {stack.status}")
|
279
284
|
# update resource status
|
280
285
|
stack.set_resource_status(
|
281
286
|
logical_resource_id=logical_resource_id,
|
@@ -288,8 +293,6 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
288
293
|
resource_status_reason=reason,
|
289
294
|
)
|
290
295
|
return
|
291
|
-
else:
|
292
|
-
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
|
293
296
|
|
294
297
|
self.resources.setdefault(logical_resource_id, {"Properties": {}})
|
295
298
|
match event.status:
|
@@ -341,13 +344,15 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
341
344
|
)
|
342
345
|
# TODO: duplication
|
343
346
|
stack = self._change_set.stack
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
347
|
+
match stack.status:
|
348
|
+
case StackStatus.CREATE_IN_PROGRESS:
|
349
|
+
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
|
350
|
+
case StackStatus.UPDATE_IN_PROGRESS:
|
351
|
+
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
|
352
|
+
case StackStatus.DELETE_IN_PROGRESS:
|
353
|
+
stack.set_stack_status(StackStatus.DELETE_FAILED, reason=reason)
|
354
|
+
case _:
|
355
|
+
raise NotImplementedError(f"Unhandled stack status: '{stack.status}'")
|
351
356
|
stack.set_resource_status(
|
352
357
|
logical_resource_id=logical_resource_id,
|
353
358
|
# TODO
|
@@ -358,8 +363,8 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
358
363
|
else ResourceStatus.UPDATE_FAILED,
|
359
364
|
resource_status_reason=reason,
|
360
365
|
)
|
361
|
-
case
|
362
|
-
raise NotImplementedError(f"Event status '{
|
366
|
+
case other:
|
367
|
+
raise NotImplementedError(f"Event status '{other}' not handled")
|
363
368
|
|
364
369
|
def create_resource_provider_payload(
|
365
370
|
self,
|
@@ -387,7 +392,9 @@ class ChangeSetModelExecutor(ChangeSetModelPreproc):
|
|
387
392
|
previous_resource_properties = before_properties_value or {}
|
388
393
|
case ChangeAction.Remove:
|
389
394
|
resource_properties = before_properties_value or {}
|
390
|
-
previous_resource_properties = None
|
395
|
+
# previous_resource_properties = None
|
396
|
+
# HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both
|
397
|
+
previous_resource_properties = resource_properties
|
391
398
|
case _:
|
392
399
|
raise NotImplementedError(f"Action '{action}' not handled")
|
393
400
|
|
@@ -19,7 +19,7 @@ from plux import Plugin, PluginManager
|
|
19
19
|
|
20
20
|
from localstack import config
|
21
21
|
from localstack.aws.connect import InternalClientFactory, ServiceLevelClientFactory
|
22
|
-
from localstack.services.cloudformation import
|
22
|
+
from localstack.services.cloudformation import analytics
|
23
23
|
from localstack.services.cloudformation.deployment_utils import (
|
24
24
|
check_not_found_exception,
|
25
25
|
convert_data_types,
|
@@ -581,7 +581,7 @@ class ResourceProviderExecutor:
|
|
581
581
|
# 2. try to load community resource provider
|
582
582
|
try:
|
583
583
|
plugin = plugin_manager.load(resource_type)
|
584
|
-
|
584
|
+
analytics.resources.labels(resource_type=resource_type, missing=False).increment()
|
585
585
|
return plugin.factory()
|
586
586
|
except ValueError:
|
587
587
|
# could not find a plugin for that name
|
@@ -600,7 +600,7 @@ class ResourceProviderExecutor:
|
|
600
600
|
f'No resource provider found for "{resource_type}"',
|
601
601
|
)
|
602
602
|
|
603
|
-
|
603
|
+
analytics.resources.labels(resource_type=resource_type, missing=True).increment()
|
604
604
|
|
605
605
|
if config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
|
606
606
|
# TODO: figure out a better way to handle non-implemented here?
|
@@ -43,6 +43,7 @@ class Stack:
|
|
43
43
|
status_reason: StackStatusReason | None
|
44
44
|
stack_id: str
|
45
45
|
creation_time: datetime
|
46
|
+
deletion_time: datetime | None
|
46
47
|
|
47
48
|
# state after deploy
|
48
49
|
resolved_parameters: dict[str, str]
|
@@ -67,6 +68,7 @@ class Stack:
|
|
67
68
|
self.status_reason = None
|
68
69
|
self.change_set_ids = change_set_ids or []
|
69
70
|
self.creation_time = datetime.now(tz=timezone.utc)
|
71
|
+
self.deletion_time = None
|
70
72
|
|
71
73
|
self.stack_name = request_payload["StackName"]
|
72
74
|
self.change_set_name = request_payload.get("ChangeSetName")
|
@@ -118,6 +120,7 @@ class Stack:
|
|
118
120
|
result = {
|
119
121
|
"ChangeSetId": self.change_set_id,
|
120
122
|
"CreationTime": self.creation_time,
|
123
|
+
"DeletionTime": self.deletion_time,
|
121
124
|
"StackId": self.stack_id,
|
122
125
|
"StackName": self.stack_name,
|
123
126
|
"StackStatus": self.status,
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import copy
|
2
2
|
import logging
|
3
|
+
from datetime import datetime, timezone
|
3
4
|
from typing import Any
|
4
5
|
|
5
6
|
from localstack.aws.api import RequestContext, handler
|
@@ -101,7 +102,7 @@ def find_change_set_v2(
|
|
101
102
|
# TODO: check for active stacks
|
102
103
|
if (
|
103
104
|
stack_candidate.stack_name == stack_name
|
104
|
-
and
|
105
|
+
and stack_candidate.status != StackStatus.DELETE_COMPLETE
|
105
106
|
):
|
106
107
|
stack = stack_candidate
|
107
108
|
break
|
@@ -175,10 +176,10 @@ class CloudformationProviderV2(CloudformationProvider):
|
|
175
176
|
# on a CREATE an empty Stack should be generated if we didn't find an active one
|
176
177
|
if not active_stack_candidates and change_set_type == ChangeSetType.CREATE:
|
177
178
|
stack = Stack(
|
178
|
-
context.account_id,
|
179
|
-
context.region,
|
180
|
-
request,
|
181
|
-
structured_template,
|
179
|
+
account_id=context.account_id,
|
180
|
+
region_name=context.region,
|
181
|
+
request_payload=request,
|
182
|
+
template=structured_template,
|
182
183
|
template_body=template_body,
|
183
184
|
)
|
184
185
|
state.stacks_v2[stack.stack_id] = stack
|
@@ -240,7 +241,7 @@ class CloudformationProviderV2(CloudformationProvider):
|
|
240
241
|
after_template = structured_template
|
241
242
|
|
242
243
|
# create change set for the stack and apply changes
|
243
|
-
change_set = ChangeSet(stack, request)
|
244
|
+
change_set = ChangeSet(stack, request, template=after_template)
|
244
245
|
|
245
246
|
# only set parameters for the changeset, then switch to stack on execute_change_set
|
246
247
|
change_set.populate_update_graph(
|
@@ -309,6 +310,9 @@ class CloudformationProviderV2(CloudformationProvider):
|
|
309
310
|
change_set.stack.resolved_resources = result.resources
|
310
311
|
change_set.stack.resolved_parameters = result.parameters
|
311
312
|
change_set.stack.resolved_outputs = result.outputs
|
313
|
+
# if the deployment succeeded, update the stack's template representation to that
|
314
|
+
# which was just deployed
|
315
|
+
change_set.stack.template = change_set.template
|
312
316
|
except Exception as e:
|
313
317
|
LOG.error(
|
314
318
|
"Execute change set failed: %s", e, exc_info=LOG.isEnabledFor(logging.WARNING)
|
@@ -458,5 +462,37 @@ class CloudformationProviderV2(CloudformationProvider):
|
|
458
462
|
# aws will silently ignore invalid stack names - we should do the same
|
459
463
|
return
|
460
464
|
|
461
|
-
#
|
462
|
-
|
465
|
+
# shortcut for stacks which have no deployed resources i.e. where a change set was
|
466
|
+
# created, but never executed
|
467
|
+
if stack.status == StackStatus.REVIEW_IN_PROGRESS and not stack.resolved_resources:
|
468
|
+
stack.set_stack_status(StackStatus.DELETE_COMPLETE)
|
469
|
+
stack.deletion_time = datetime.now(tz=timezone.utc)
|
470
|
+
return
|
471
|
+
|
472
|
+
# create a dummy change set
|
473
|
+
change_set = ChangeSet(stack, {"ChangeSetName": f"delete-stack_{stack.stack_name}"}) # noqa
|
474
|
+
change_set.populate_update_graph(
|
475
|
+
before_template=stack.template,
|
476
|
+
after_template=None,
|
477
|
+
before_parameters=stack.resolved_parameters,
|
478
|
+
after_parameters=None,
|
479
|
+
)
|
480
|
+
|
481
|
+
change_set_executor = ChangeSetModelExecutor(change_set)
|
482
|
+
|
483
|
+
def _run(*args):
|
484
|
+
try:
|
485
|
+
stack.set_stack_status(StackStatus.DELETE_IN_PROGRESS)
|
486
|
+
change_set_executor.execute()
|
487
|
+
stack.set_stack_status(StackStatus.DELETE_COMPLETE)
|
488
|
+
stack.deletion_time = datetime.now(tz=timezone.utc)
|
489
|
+
except Exception as e:
|
490
|
+
LOG.warning(
|
491
|
+
"Failed to delete stack '%s': %s",
|
492
|
+
stack.stack_name,
|
493
|
+
e,
|
494
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
495
|
+
)
|
496
|
+
stack.set_stack_status(StackStatus.DELETE_FAILED)
|
497
|
+
|
498
|
+
start_worker_thread(_run)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
from enum import StrEnum
|
2
2
|
|
3
|
-
from localstack.utils.analytics.metrics import
|
3
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
4
4
|
|
5
5
|
|
6
6
|
class InvocationStatus(StrEnum):
|
@@ -11,4 +11,6 @@ class InvocationStatus(StrEnum):
|
|
11
11
|
# number of EventBridge rule invocations per target (e.g., aws:lambda)
|
12
12
|
# - status label can be `success` or `error`, see InvocationStatus
|
13
13
|
# - service label is the target service name
|
14
|
-
rule_invocation =
|
14
|
+
rule_invocation = LabeledCounter(
|
15
|
+
namespace="events", name="rule_invocations", labels=["status", "service"]
|
16
|
+
)
|
@@ -149,7 +149,7 @@ class KinesisStreamProvider(ResourceProvider[KinesisStreamProperties]):
|
|
149
149
|
client.describe_stream(StreamARN=model["Arn"])
|
150
150
|
return ProgressEvent(
|
151
151
|
status=OperationStatus.IN_PROGRESS,
|
152
|
-
resource_model=
|
152
|
+
resource_model=model,
|
153
153
|
)
|
154
154
|
except client.exceptions.ResourceNotFoundException:
|
155
155
|
return ProgressEvent(
|
@@ -1,12 +1,12 @@
|
|
1
1
|
from enum import StrEnum
|
2
2
|
|
3
|
-
from localstack.utils.analytics.metrics import
|
3
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
4
4
|
|
5
5
|
NAMESPACE = "lambda"
|
6
6
|
|
7
|
-
hotreload_counter =
|
7
|
+
hotreload_counter = LabeledCounter(namespace=NAMESPACE, name="hotreload", labels=["operation"])
|
8
8
|
|
9
|
-
function_counter =
|
9
|
+
function_counter = LabeledCounter(
|
10
10
|
namespace=NAMESPACE,
|
11
11
|
name="function",
|
12
12
|
labels=[
|
@@ -38,7 +38,7 @@ class FunctionStatus(StrEnum):
|
|
38
38
|
invocation_error = "invocation_error"
|
39
39
|
|
40
40
|
|
41
|
-
esm_counter =
|
41
|
+
esm_counter = LabeledCounter(namespace=NAMESPACE, name="esm", labels=["source", "status"])
|
42
42
|
|
43
43
|
|
44
44
|
class EsmExecutionStatus(StrEnum):
|
@@ -2,8 +2,10 @@
|
|
2
2
|
Usage analytics for SNS internal endpoints
|
3
3
|
"""
|
4
4
|
|
5
|
-
from localstack.utils.analytics.metrics import
|
5
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
6
6
|
|
7
7
|
# number of times SNS internal endpoint per resource types
|
8
8
|
# (e.g. PlatformMessage invoked 10x times, SMSMessage invoked 3x times, SubscriptionToken...)
|
9
|
-
internal_api_calls =
|
9
|
+
internal_api_calls = LabeledCounter(
|
10
|
+
namespace="sns", name="internal_api_call", labels=["resource_type"]
|
11
|
+
)
|
@@ -2,10 +2,10 @@
|
|
2
2
|
Usage reporting for StepFunctions service
|
3
3
|
"""
|
4
4
|
|
5
|
-
from localstack.utils.analytics.metrics import
|
5
|
+
from localstack.utils.analytics.metrics import LabeledCounter
|
6
6
|
|
7
7
|
# Initialize a counter to record the usage of language features for each state machine.
|
8
|
-
language_features_counter =
|
8
|
+
language_features_counter = LabeledCounter(
|
9
9
|
namespace="stepfunctions",
|
10
10
|
name="language_features_used",
|
11
11
|
labels=["query_language", "uses_variables"],
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
3
3
|
import logging
|
4
4
|
from typing import Final
|
5
5
|
|
6
|
-
|
6
|
+
from localstack.services.stepfunctions import analytics
|
7
7
|
from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser
|
8
8
|
from localstack.services.stepfunctions.asl.component.common.query_language import (
|
9
9
|
QueryLanguageMode,
|
@@ -40,7 +40,7 @@ class UsageMetricsStaticAnalyser(StaticAnalyser):
|
|
40
40
|
uses_variables = analyser.uses_variables
|
41
41
|
|
42
42
|
# Count.
|
43
|
-
|
43
|
+
analytics.language_features_counter.labels(
|
44
44
|
query_language=language_used, uses_variables=uses_variables
|
45
45
|
).increment()
|
46
46
|
except Exception as e:
|
@@ -0,0 +1,42 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from typing import Any, Protocol
|
5
|
+
|
6
|
+
|
7
|
+
class Payload(Protocol):
|
8
|
+
def as_dict(self) -> dict[str, Any]: ...
|
9
|
+
|
10
|
+
|
11
|
+
class Metric(ABC):
|
12
|
+
"""
|
13
|
+
Base class for all metrics (e.g., Counter, Gauge).
|
14
|
+
Each subclass must implement the `collect()` method.
|
15
|
+
"""
|
16
|
+
|
17
|
+
_namespace: str
|
18
|
+
_name: str
|
19
|
+
|
20
|
+
def __init__(self, namespace: str, name: str):
|
21
|
+
if not namespace or namespace.strip() == "":
|
22
|
+
raise ValueError("Namespace must be non-empty string.")
|
23
|
+
self._namespace = namespace
|
24
|
+
|
25
|
+
if not name or name.strip() == "":
|
26
|
+
raise ValueError("Metric name must be non-empty string.")
|
27
|
+
self._name = name
|
28
|
+
|
29
|
+
@property
|
30
|
+
def namespace(self) -> str:
|
31
|
+
return self._namespace
|
32
|
+
|
33
|
+
@property
|
34
|
+
def name(self) -> str:
|
35
|
+
return self._name
|
36
|
+
|
37
|
+
@abstractmethod
|
38
|
+
def collect(self) -> list[Payload]:
|
39
|
+
"""
|
40
|
+
Collects and returns metric data. Subclasses must implement this to return collected metric data.
|
41
|
+
"""
|
42
|
+
pass
|
@@ -0,0 +1,209 @@
|
|
1
|
+
import threading
|
2
|
+
from collections import defaultdict
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import Any, Optional, Union
|
5
|
+
|
6
|
+
from localstack import config
|
7
|
+
|
8
|
+
from .api import Metric
|
9
|
+
from .registry import MetricRegistry
|
10
|
+
|
11
|
+
|
12
|
+
@dataclass(frozen=True)
|
13
|
+
class CounterPayload:
|
14
|
+
"""A data object storing the value of a Counter metric."""
|
15
|
+
|
16
|
+
namespace: str
|
17
|
+
name: str
|
18
|
+
value: int
|
19
|
+
type: str
|
20
|
+
|
21
|
+
def as_dict(self) -> dict[str, Any]:
|
22
|
+
return {
|
23
|
+
"namespace": self.namespace,
|
24
|
+
"name": self.name,
|
25
|
+
"value": self.value,
|
26
|
+
"type": self.type,
|
27
|
+
}
|
28
|
+
|
29
|
+
|
30
|
+
@dataclass(frozen=True)
|
31
|
+
class LabeledCounterPayload:
|
32
|
+
"""A data object storing the value of a LabeledCounter metric."""
|
33
|
+
|
34
|
+
namespace: str
|
35
|
+
name: str
|
36
|
+
value: int
|
37
|
+
type: str
|
38
|
+
labels: dict[str, Union[str, float]]
|
39
|
+
|
40
|
+
def as_dict(self) -> dict[str, Any]:
|
41
|
+
payload_dict = {
|
42
|
+
"namespace": self.namespace,
|
43
|
+
"name": self.name,
|
44
|
+
"value": self.value,
|
45
|
+
"type": self.type,
|
46
|
+
}
|
47
|
+
|
48
|
+
for i, (label_name, label_value) in enumerate(self.labels.items(), 1):
|
49
|
+
payload_dict[f"label_{i}"] = label_name
|
50
|
+
payload_dict[f"label_{i}_value"] = label_value
|
51
|
+
|
52
|
+
return payload_dict
|
53
|
+
|
54
|
+
|
55
|
+
class ThreadSafeCounter:
|
56
|
+
"""
|
57
|
+
A thread-safe counter for any kind of tracking.
|
58
|
+
This class should not be instantiated directly, use Counter or LabeledCounter instead.
|
59
|
+
"""
|
60
|
+
|
61
|
+
_mutex: threading.Lock
|
62
|
+
_count: int
|
63
|
+
|
64
|
+
def __init__(self):
|
65
|
+
super(ThreadSafeCounter, self).__init__()
|
66
|
+
self._mutex = threading.Lock()
|
67
|
+
self._count = 0
|
68
|
+
|
69
|
+
@property
|
70
|
+
def count(self) -> int:
|
71
|
+
return self._count
|
72
|
+
|
73
|
+
def increment(self, value: int = 1) -> None:
|
74
|
+
"""Increments the counter unless events are disabled."""
|
75
|
+
if config.DISABLE_EVENTS:
|
76
|
+
return
|
77
|
+
|
78
|
+
if value <= 0:
|
79
|
+
raise ValueError("Increment value must be positive.")
|
80
|
+
|
81
|
+
with self._mutex:
|
82
|
+
self._count += value
|
83
|
+
|
84
|
+
def reset(self) -> None:
|
85
|
+
"""Resets the counter to zero unless events are disabled."""
|
86
|
+
if config.DISABLE_EVENTS:
|
87
|
+
return
|
88
|
+
|
89
|
+
with self._mutex:
|
90
|
+
self._count = 0
|
91
|
+
|
92
|
+
|
93
|
+
class Counter(Metric, ThreadSafeCounter):
|
94
|
+
"""
|
95
|
+
A thread-safe, unlabeled counter for tracking the total number of occurrences of a specific event.
|
96
|
+
This class is intended for metrics that do not require differentiation across dimensions.
|
97
|
+
For use cases where metrics need to be grouped or segmented by labels, use `LabeledCounter` instead.
|
98
|
+
"""
|
99
|
+
|
100
|
+
_type: str
|
101
|
+
|
102
|
+
def __init__(self, namespace: str, name: str):
|
103
|
+
Metric.__init__(self, namespace=namespace, name=name)
|
104
|
+
ThreadSafeCounter.__init__(self)
|
105
|
+
|
106
|
+
self._type = "counter"
|
107
|
+
|
108
|
+
MetricRegistry().register(self)
|
109
|
+
|
110
|
+
def collect(self) -> list[CounterPayload]:
|
111
|
+
"""Collects the metric unless events are disabled."""
|
112
|
+
if config.DISABLE_EVENTS:
|
113
|
+
return list()
|
114
|
+
|
115
|
+
if self._count == 0:
|
116
|
+
# Return an empty list if the count is 0, as there are no metrics to send to the analytics backend.
|
117
|
+
return list()
|
118
|
+
|
119
|
+
return [
|
120
|
+
CounterPayload(
|
121
|
+
namespace=self._namespace, name=self.name, value=self._count, type=self._type
|
122
|
+
)
|
123
|
+
]
|
124
|
+
|
125
|
+
|
126
|
+
class LabeledCounter(Metric):
|
127
|
+
"""
|
128
|
+
A thread-safe counter for tracking occurrences of an event across multiple combinations of label values.
|
129
|
+
It enables fine-grained metric collection and analysis, with each unique label set stored and counted independently.
|
130
|
+
Use this class when you need dimensional insights into event occurrences.
|
131
|
+
For simpler, unlabeled use cases, see the `Counter` class.
|
132
|
+
"""
|
133
|
+
|
134
|
+
_type: str
|
135
|
+
_labels: list[str]
|
136
|
+
_label_values: tuple[Optional[Union[str, float]], ...]
|
137
|
+
_counters_by_label_values: defaultdict[
|
138
|
+
tuple[Optional[Union[str, float]], ...], ThreadSafeCounter
|
139
|
+
]
|
140
|
+
|
141
|
+
def __init__(self, namespace: str, name: str, labels: list[str]):
|
142
|
+
super(LabeledCounter, self).__init__(namespace=namespace, name=name)
|
143
|
+
|
144
|
+
if not labels:
|
145
|
+
raise ValueError("At least one label is required; the labels list cannot be empty.")
|
146
|
+
|
147
|
+
if any(not label for label in labels):
|
148
|
+
raise ValueError("Labels must be non-empty strings.")
|
149
|
+
|
150
|
+
if len(labels) > 6:
|
151
|
+
raise ValueError("Too many labels: counters allow a maximum of 6.")
|
152
|
+
|
153
|
+
self._type = "counter"
|
154
|
+
self._labels = labels
|
155
|
+
self._counters_by_label_values = defaultdict(ThreadSafeCounter)
|
156
|
+
MetricRegistry().register(self)
|
157
|
+
|
158
|
+
def labels(self, **kwargs: Union[str, float, None]) -> ThreadSafeCounter:
|
159
|
+
"""
|
160
|
+
Create a scoped counter instance with specific label values.
|
161
|
+
|
162
|
+
This method assigns values to the predefined labels of a labeled counter and returns
|
163
|
+
a ThreadSafeCounter object that allows tracking metrics for that specific
|
164
|
+
combination of label values.
|
165
|
+
|
166
|
+
:raises ValueError:
|
167
|
+
- If the set of keys provided labels does not match the expected set of labels.
|
168
|
+
"""
|
169
|
+
if set(self._labels) != set(kwargs.keys()):
|
170
|
+
raise ValueError(f"Expected labels {self._labels}, got {list(kwargs.keys())}")
|
171
|
+
|
172
|
+
_label_values = tuple(kwargs[label] for label in self._labels)
|
173
|
+
|
174
|
+
return self._counters_by_label_values[_label_values]
|
175
|
+
|
176
|
+
def collect(self) -> list[LabeledCounterPayload]:
|
177
|
+
if config.DISABLE_EVENTS:
|
178
|
+
return list()
|
179
|
+
|
180
|
+
payload = []
|
181
|
+
num_labels = len(self._labels)
|
182
|
+
|
183
|
+
for label_values, counter in self._counters_by_label_values.items():
|
184
|
+
if counter.count == 0:
|
185
|
+
continue # Skip items with a count of 0, as they should not be sent to the analytics backend.
|
186
|
+
|
187
|
+
if len(label_values) != num_labels:
|
188
|
+
raise ValueError(
|
189
|
+
f"Label count mismatch: expected {num_labels} labels {self._labels}, "
|
190
|
+
f"but got {len(label_values)} values {label_values}."
|
191
|
+
)
|
192
|
+
|
193
|
+
# Create labels dictionary
|
194
|
+
labels_dict = {
|
195
|
+
label_name: label_value
|
196
|
+
for label_name, label_value in zip(self._labels, label_values)
|
197
|
+
}
|
198
|
+
|
199
|
+
payload.append(
|
200
|
+
LabeledCounterPayload(
|
201
|
+
namespace=self._namespace,
|
202
|
+
name=self.name,
|
203
|
+
value=counter.count,
|
204
|
+
type=self._type,
|
205
|
+
labels=labels_dict,
|
206
|
+
)
|
207
|
+
)
|
208
|
+
|
209
|
+
return payload
|
@@ -0,0 +1,36 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
|
3
|
+
from localstack import config
|
4
|
+
from localstack.runtime import hooks
|
5
|
+
from localstack.utils.analytics import get_session_id
|
6
|
+
from localstack.utils.analytics.events import Event, EventMetadata
|
7
|
+
from localstack.utils.analytics.publisher import AnalyticsClientPublisher
|
8
|
+
|
9
|
+
from .registry import MetricRegistry
|
10
|
+
|
11
|
+
|
12
|
+
@hooks.on_infra_shutdown()
|
13
|
+
def publish_metrics() -> None:
|
14
|
+
"""
|
15
|
+
Collects all the registered metrics and immediately sends them to the analytics service.
|
16
|
+
Skips execution if event tracking is disabled (`config.DISABLE_EVENTS`).
|
17
|
+
|
18
|
+
This function is automatically triggered on infrastructure shutdown.
|
19
|
+
"""
|
20
|
+
if config.DISABLE_EVENTS:
|
21
|
+
return
|
22
|
+
|
23
|
+
collected_metrics = MetricRegistry().collect()
|
24
|
+
if not collected_metrics.payload: # Skip publishing if no metrics remain after filtering
|
25
|
+
return
|
26
|
+
|
27
|
+
metadata = EventMetadata(
|
28
|
+
session_id=get_session_id(),
|
29
|
+
client_time=str(datetime.now()),
|
30
|
+
)
|
31
|
+
|
32
|
+
if collected_metrics:
|
33
|
+
publisher = AnalyticsClientPublisher()
|
34
|
+
publisher.publish(
|
35
|
+
[Event(name="ls_metrics", metadata=metadata, payload=collected_metrics.as_dict())]
|
36
|
+
)
|