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.
Files changed (31) hide show
  1. localstack/services/apigateway/analytics.py +2 -2
  2. localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py +3 -3
  3. localstack/services/cloudformation/{usage.py → analytics.py} +2 -2
  4. localstack/services/cloudformation/engine/v2/change_set_model_executor.py +25 -18
  5. localstack/services/cloudformation/resource_provider.py +3 -3
  6. localstack/services/cloudformation/v2/entities.py +3 -0
  7. localstack/services/cloudformation/v2/provider.py +44 -8
  8. localstack/services/events/analytics.py +4 -2
  9. localstack/services/kinesis/resource_providers/aws_kinesis_stream.py +1 -1
  10. localstack/services/lambda_/analytics.py +4 -4
  11. localstack/services/sns/analytics.py +4 -2
  12. localstack/services/stepfunctions/{usage.py → analytics.py} +2 -2
  13. localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py +2 -2
  14. localstack/utils/analytics/metrics/__init__.py +6 -0
  15. localstack/utils/analytics/metrics/api.py +42 -0
  16. localstack/utils/analytics/metrics/counter.py +209 -0
  17. localstack/utils/analytics/metrics/publisher.py +36 -0
  18. localstack/utils/analytics/metrics/registry.py +97 -0
  19. localstack/version.py +2 -2
  20. {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/METADATA +1 -1
  21. {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/RECORD +29 -25
  22. {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/entry_points.txt +1 -1
  23. localstack_core-4.5.1.dev23.dist-info/plux.json +1 -0
  24. localstack/utils/analytics/metrics.py +0 -373
  25. localstack_core-4.5.1.dev21.dist-info/plux.json +0 -1
  26. {localstack_core-4.5.1.dev21.data → localstack_core-4.5.1.dev23.data}/scripts/localstack +0 -0
  27. {localstack_core-4.5.1.dev21.data → localstack_core-4.5.1.dev23.data}/scripts/localstack-supervisor +0 -0
  28. {localstack_core-4.5.1.dev21.data → localstack_core-4.5.1.dev23.data}/scripts/localstack.bat +0 -0
  29. {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/WHEEL +0 -0
  30. {localstack_core-4.5.1.dev21.dist-info → localstack_core-4.5.1.dev23.dist-info}/licenses/LICENSE.txt +0 -0
  31. {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 Counter
1
+ from localstack.utils.analytics.metrics import LabeledCounter
2
2
 
3
- invocation_counter = 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 LabeledCounterMetric
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: LabeledCounterMetric
13
+ counter: LabeledCounter
14
14
 
15
- def __init__(self, counter: LabeledCounterMetric):
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 Counter
1
+ from localstack.utils.analytics.metrics import LabeledCounter
2
2
 
3
3
  COUNTER_NAMESPACE = "cloudformation"
4
4
 
5
- resources = Counter(
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.visit_node_resource(node_resource)
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
- stack_status = stack.status
275
- if stack_status == StackStatus.CREATE_IN_PROGRESS:
276
- stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
277
- elif stack_status == StackStatus.UPDATE_IN_PROGRESS:
278
- stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
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
- stack_status = stack.status
345
- if stack_status == StackStatus.CREATE_IN_PROGRESS:
346
- stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
347
- elif stack_status == StackStatus.UPDATE_IN_PROGRESS:
348
- stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
349
- else:
350
- raise NotImplementedError(f"Unhandled stack status: '{stack.status}'")
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 any:
362
- raise NotImplementedError(f"Event status '{any}' not handled")
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 usage
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
- usage.resources.labels(resource_type=resource_type, missing=False).increment()
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
- usage.resources.labels(resource_type=resource_type, missing=True).increment()
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 stack.status != StackStatus.DELETE_COMPLETE
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
- # TODO: actually delete
462
- stack.set_stack_status(StackStatus.DELETE_COMPLETE)
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 Counter
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 = Counter(namespace="events", name="rule_invocations", labels=["status", "service"])
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 Counter
3
+ from localstack.utils.analytics.metrics import LabeledCounter
4
4
 
5
5
  NAMESPACE = "lambda"
6
6
 
7
- hotreload_counter = Counter(namespace=NAMESPACE, name="hotreload", labels=["operation"])
7
+ hotreload_counter = LabeledCounter(namespace=NAMESPACE, name="hotreload", labels=["operation"])
8
8
 
9
- function_counter = 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 = Counter(namespace=NAMESPACE, name="esm", labels=["source", "status"])
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 Counter
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 = Counter(namespace="sns", name="internal_api_call", labels=["resource_type"])
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 Counter
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 = 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
- import localstack.services.stepfunctions.usage as UsageMetrics
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
- UsageMetrics.language_features_counter.labels(
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,6 @@
1
+ """LocalStack metrics instrumentation framework"""
2
+
3
+ from .counter import Counter, LabeledCounter
4
+ from .registry import MetricRegistry, MetricRegistryKey
5
+
6
+ __all__ = ["Counter", "LabeledCounter", "MetricRegistry", "MetricRegistryKey"]
@@ -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
+ )