8000 User-Agents analytics for developer endpoints by giograno · Pull Request #11855 · localstack/localstack · GitHub
[go: up one dir, main page]

Skip to content
8000

User-Agents analytics for developer endpoints #11855

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions localstack-core/localstack/aws/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def __init__(self, service_manager: ServiceManager = None) -> None:
handlers.add_cors_response_headers,
handlers.log_response,
handlers.count_service_request,
handlers.user_agent_request,
metric_collector.update_metric_collection,
]
)
Expand Down
1 change: 1 addition & 0 deletions localstack-core/localstack/aws/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
log_exception = logging.ExceptionLogger()
log_response = logging.ResponseLogger()
count_service_request = analytics.ServiceRequestCounter()
user_agent_request = analytics.UserAgentCounter()
handle_service_exception = service.ServiceExceptionSerializer()
handle_internal_failure = fallback.InternalFailureHandler()
serve_custom_service_request_handlers = chain.CompositeHandler()
Expand Down
53 changes: 52 additions & 1 deletion localstack-core/localstack/aws/handlers/analytics.py
8000
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import logging
import threading
from typing import Optional
from typing import Any, Optional

from localstack import config
from localstack.aws.api import RequestContext
from localstack.aws.chain import HandlerChain
from localstack.aws.client import parse_response
from localstack.constants import INTERNAL_RESOURCE_PATH
from localstack.http import Response
from localstack.utils.analytics.service_request_aggregator import (
ServiceRequestAggregator,
ServiceRequestInfo,
)
from localstack.utils.analytics.usage import UsageSetCounter

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -67,3 +69,52 @@ def _get_err_type(self, context: RequestContext, response: Response) -> Optional
if config.DEBUG_ANALYTICS:
LOG.exception("error parsing error response")
return None


class UsageCollectorFactory:
_collector_registry: dict[str, Any] = {}
"""Registry for the different paths."""

NAMESPACE_PREFIX = "agent:"
"""Namespace prefix to track usage of public endpoints (_localstack/ and _aws/)."""

@classmethod
def get_collector(cls, path: str):
namespace = f"{cls.NAMESPACE_PREFIX}{path}"
if namespace not in cls._collector_registry:
cls._collector_registry[namespace] = UsageSetCounter(namespace)
return cls._collector_registry[namespace]


class UserAgentCounter:
"""
This handler collects User-Agents analytics for the LocalStack public endpoints (the ones with a _localstack or a
_aws prefix).
"""

def _record_usage(self, context: RequestContext) -> None:
request_path = context.request.path.lstrip("/")
user_agent = context.request.headers.get("User-Agent")
if not request_path or not user_agent:
return
# Skip the endpoints for the new API Gateway implementation
if "execute-api" in request_path:
return
# We only record the first segment in the path after the _internal/ or _aws/ prefix, as a path can have
# potentially an infinite number of parameters.
recorded_path = request_path.split("/")[:2]
if len(recorded_path) < 2:
return
recorded_path = "/".join(recorded_path)
collector = UsageCollectorFactory.get_collector(recorded_path)
collector.record(user_agent)

def __call__(self, chain: HandlerChain, context: RequestContext, response: Response):
if config.DISABLE_EVENTS:
return

path = context.request.path
if not (path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/")):
return

self._record_usage(context)
75 changes: 39 additions & 36 deletions localstack-core/localstack/aws/handlers/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,33 @@ class OpenAPIRequestValidator(OpenAPIValidator):
"""

def __call__(self, chain: HandlerChain, context: RequestContext, response: Response):
path = context.request.path
if not (path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/")):
return

if not config.OPENAPI_VALIDATE_REQUEST:
return

hasattr(self, "open_apis") or self._load_specs()
path = context.request.path

if path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/"):
for openapi in self.open_apis:
try:
openapi.validate_request(WerkzeugOpenAPIRequest(context.request))
# We stop the handler at the first succeeded validation, as the other spec might not even specify
# this path.
break
except RequestValidationError as e:
# Note: in this handler we only check validation errors, e.g., wrong body, missing required.
response.status_code = 400
response.set_json({"error": "Bad Request", "message": str(e)})
chain.stop()
except OpenAPIError:
# Other errors can be raised when validating a request against the OpenAPI specification.
# The most common are: ServerNotFound, OperationNotFound, or PathNotFound.
# We explicitly do not check any other error but RequestValidationError ones.
# We shallow the exception to avoid excessive logging (e.g., a lot of ServerNotFound), as the only
# purpose of this handler is to check for request validation errors.
pass
for openapi in self.open_apis:
try:
openapi.validate_request(WerkzeugOpenAPIRequest(context.request))
# We stop the handler at the first succeeded validation, as the other spec might not even specify
# this path.
break
except RequestValidationError as e:
# Note: in this handler we only check validation errors, e.g., wrong body, missing required.
response.status_code = 400
response.set_json({"error": "Bad Request", "message": str(e)})
chain.stop()
except OpenAPIError:
# Other errors can be raised when validating a request against the OpenAPI specification.
# The most common are: ServerNotFound, OperationNotFound, or PathNotFound.
# We explicitly do not check any other error but RequestValidationError ones.
# We shallow the exception to avoid excessive logging (e.g., a lot of ServerNotFound), as the only
# purpose of this handler is to check for request validation errors.
pass


class OpenAPIResponseValidator(OpenAPIValidator):
Expand All @@ -81,20 +83,21 @@ def __call__(self, chain: HandlerChain, context: RequestContext, response: Respo

hasattr(self, "open_apis") or self._load_specs()
path = context.request.path
if not (path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/")):
return

if path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/"):
for openapi in self.open_apis:
try:
openapi.validate_response(
WerkzeugOpenAPIRequest(context.request),
WerkzeugOpenAPIResponse(response),
)
break
except ResponseValidationError as exc:
LOG.error("Response validation failed for %s: %s", path, exc)
response.status_code = 500
response.set_json({"error": exc.__class__.__name__, "message": str(exc)})
chain.terminate()
except OpenAPIError:
# Same logic from the request validator applies here.
pass
for openapi in self.open_apis:
try:
openapi.validate_response(
WerkzeugOpenAPIRequest(context.request),
WerkzeugOpenAPIResponse(response),
)
break
except ResponseValidationError as exc:
LOG.error("Response validation failed for %s: %s", path, exc)
response.status_code = 500
response.set_json({"error": exc.__class__.__name__, "message": str(exc)})
chain.terminate()
except OpenAPIError:
# Same logic from the request validator applies here.
pass
Loading
0