File: C:/Users/fred/anaconda3/Lib/site-packages/anaconda_navigator/utils/telemetry/core.py
# -*- coding: utf8 -*-
"""Primary bases for telemetry processing."""
from __future__ import annotations
__all__ = ['Event', 'Context', 'Provider', 'Pool', 'Analytics']
import abc
import collections.abc
import datetime
import queue
import typing
import uuid
from anaconda_navigator.utils import workers
if typing.TYPE_CHECKING:
import typing_extensions
KeyT = typing.TypeVar('KeyT', bound=collections.abc.Hashable)
ValueT = typing.TypeVar('ValueT')
EventProperty: typing_extensions.TypeAlias = 'str | int'
EventPropertyCollection: typing_extensions.TypeAlias = 'collections.abc.Mapping[str, EventProperty]'
MappingSource: typing_extensions.TypeAlias = (
'collections.abc.Mapping[KeyT, ValueT] | collections.abc.Iterable[tuple[KeyT, ValueT]]'
)
def generate_identifier() -> str:
"""Generate random identifier string."""
return f'{uuid.uuid4().hex}{uuid.uuid4().hex}'
class Event:
"""Telemetry event to track."""
__slots__ = ('__key', '__name', '__properties', '__timestamp', '__user_properties')
def __init__( # pylint: disable=too-many-arguments
self,
name: str,
properties: MappingSource[str, EventProperty] = (), # pylint: disable=invalid-sequence-index
*,
key: str | None = None,
timestamp: datetime.datetime | None = None,
user_properties: MappingSource[str, EventProperty] = (), # pylint: disable=invalid-sequence-index
) -> None:
"""Initialize new instance of a :class:`~Event`."""
self.__key: typing.Final[str] = str(key or uuid.uuid4().hex)
self.__name: typing.Final[str] = str(name)
self.__properties: typing.Final[EventPropertyCollection] = dict(properties)
self.__timestamp: typing.Final[datetime.datetime] = timestamp or datetime.datetime.now(datetime.timezone.utc)
self.__user_properties: typing.Final[EventPropertyCollection] = dict(user_properties)
@property
def key(self) -> str: # noqa: D401
"""Unique identifier of the event."""
return self.__key
@property
def name(self) -> str: # noqa: D401
"""Name of the event."""
return self.__name
@property
def properties(self) -> EventPropertyCollection: # noqa: D401
"""Extra properties of the event."""
return self.__properties
@property
def timestamp(self) -> datetime.datetime:
"""When event is happened."""
return self.__timestamp
@property
def user_properties(self) -> EventPropertyCollection: # noqa: D401
"""Extra properties of the user triggered the event."""
return self.__user_properties
class Context:
"""
Common context for telemetry processing.
:param user_identity: Unique identifier of the current user.
:param session_identity: Unique identifier of the current session.
"""
__slots__ = ('__session_identity', '__user_identity')
def __init__(
self,
session_identity: str | None = None,
user_identity: str | None = None,
) -> None:
"""Initialize new instance of a :class:`~Context`."""
if not session_identity:
session_identity = generate_identifier()
if not user_identity:
user_identity = generate_identifier()
self.__session_identity: typing.Final[str] = str(session_identity)
self.__user_identity: typing.Final[str] = str(user_identity)
@property
def session_identity(self) -> str: # noqa: D401
"""Unique identifier of the current session."""
return self.__session_identity
@property
def user_identity(self) -> str: # noqa: D401
"""Unique identifier of the current user."""
return self.__user_identity
class Provider(metaclass=abc.ABCMeta):
"""
Integration with telemetry service.
:param alias: Unique name of the provider.
Used to identify providers when events are sent to them.
If multiple providers use the same name - each event will be processed by at most one of them. This
might be useful for chain of responsibility implementation, but otherwise be careful not to lose
collected data.
:param context: Context with primary environment details.
"""
__slots__ = ('__alias', '__context')
def __init__(self, alias: str, context: Context) -> None:
"""Initialize new instance of a :class:`Provider`."""
self.__alias: typing.Final[str] = alias
self.__context: typing.Final[Context] = context
@property
def alias(self) -> str: # noqa: D401
"""Unique name of the provider."""
return self.__alias
@property
def context(self) -> Context: # noqa: D401
"""Context with primary environment details."""
return self.__context
@abc.abstractmethod
def close(self) -> None:
"""Close provider."""
@abc.abstractmethod
def process(self, events: collections.abc.MutableSequence[Event]) -> None:
"""
Process all pending :code:`events`.
Processed events must be removed from :code:`events`.
"""
class Pool(metaclass=abc.ABCMeta):
"""Storage and manager for telemetry events."""
__slots__ = ()
@abc.abstractmethod
def pending(self, provider: str) -> collections.abc.MutableSequence[Event]:
"""
Retrieve all currently pending events for :code:`provider`.
It must be a mutable sequence that provider can modify, and those changes must be applied to the pool. It is
required for providers to remove events that are already processed and keep only those that are yet to process.
"""
@abc.abstractmethod
def push(self, event: Event) -> None:
"""Push new :code:`event` to the pool."""
@abc.abstractmethod
def register(self, providers: collections.abc.Iterable[str]) -> None:
"""Register :code:`providers` in the pool for it to be aware of them."""
class Analytics:
"""
Core component for telemetry management.
:param providers: Provides to process telemetry with.
:param pool: Storage for telemetry events.
"""
__slots__ = ('__pool', '__providers', '__queue')
def __init__(
self,
providers: collections.abc.Iterable[Provider],
pool: Pool,
) -> None:
"""Initialize new instance of an :class:`~Analytics`."""
self.__pool: typing.Final[Pool] = pool
self.__providers: typing.Final[collections.abc.Sequence[Provider]] = tuple(providers)
self.__queue: typing.Final[queue.Queue[Event | None]] = queue.Queue()
self.__pool.register(provider.alias for provider in self.__providers)
workers.Task(self.__run, workers.AddCancelCallback(self.stop)).worker().start()
@property
def providers(self) -> collections.abc.Sequence[Provider]: # noqa: D401
"""List of provides used to process events."""
return self.__providers
@typing.overload
def event(self, name: Event) -> None:
"""Push new telemetry event."""
@typing.overload
def event( # pylint: disable=too-many-arguments
self,
name: str,
properties: MappingSource[str, EventProperty] = (), # pylint: disable=invalid-sequence-index
*,
key: str | None = None,
timestamp: datetime.datetime | None = None,
user_properties: MappingSource[str, EventProperty] = (), # pylint: disable=invalid-sequence-index
) -> None:
"""Push new telemetry event."""
def event( # pylint: disable=too-many-arguments
self,
name: str | Event,
properties: MappingSource[str, EventProperty] = (), # pylint: disable=invalid-sequence-index
*,
key: str | None = None,
timestamp: datetime.datetime | None = None,
user_properties: MappingSource[str, EventProperty] = (), # pylint: disable=invalid-sequence-index
) -> None:
"""Push new telemetry event."""
if not isinstance(name, Event):
name = Event(
key=key,
name=name,
properties=properties,
timestamp=timestamp,
user_properties=user_properties,
)
self.__queue.put(name)
def stop(self) -> None:
"""Stop processing telemetry."""
self.__queue.put(None)
def __run(self) -> None:
"""Primary thread used to process analytics."""
event: Event | None
provider: Provider
while event := self.__queue.get():
self.__pool.push(event)
for provider in self.providers:
provider.process(self.__pool.pending(provider.alias))
for provider in self.providers:
provider.close()