Repository: mozarkai/optics-framework
Pull request #55: feat: eventSDK integration to drivers
Author: login: davidamo9
Created: 2025-05-19T10:05:40Z | Merged: 2025-05-20T08:24:42Z

Integrating eventSDK: 

- from config.yaml, event_attributes_json path to be given which contains any data to be mapped to each event ( e.g. app version, app_name, user created key value pairs )
- event triggers at driver level for appium and selenium action keywords
- event trigger for verifier
- test_context to share data ( such as current_test_case name ) during run time


Diff:
diff --git a/optics_framework/api/verifier.py b/optics_framework/api/verifier.py
index cf591f3f..17adbf1e 100644
--- a/optics_framework/api/verifier.py
+++ b/optics_framework/api/verifier.py
@@ -3,7 +3,7 @@
 from optics_framework.common import utils
 from optics_framework.common.optics_builder import OpticsBuilder
 from optics_framework.common.strategies import StrategyManager
-
+from optics_framework.common.eventSDK import EventSDK
 
 class Verifier:
     """
@@ -22,6 +22,7 @@ def __init__(self, builder: OpticsBuilder):
         self.text_detection = builder.get_text_detection()
         self.strategy_manager = StrategyManager(
             self.element_source, self.text_detection, self.image_detection)
+        self.event_sdk = EventSDK().get_instance()
 
     def validate_element(
         self,
@@ -104,14 +105,18 @@ def assert_presence(self, elements: str, timeout: int = 30, rule: str = 'any', e
             return False
 
         if rule == 'any':
-            if not any(result_parts) and fail:
-                raise AssertionError("None of the elements are found.")
-        elif rule == 'all':
-            if not all(result_parts) and fail:
-                raise AssertionError("Not all elements are found.")
-        else:
-            internal_logger.exception(f"Unknown rule: {rule}")
-            return False
+            result = any(result_parts)
+
+        else:  # rule == 'all'
+            result = all(result_parts)
+
+        if event_name and result:
+            self.event_sdk.capture_event(event_name)
+
+        if not result and fail:
+            raise AssertionError("Presence assertion failed based on rule: " + rule)
+
+        return result
 
 
     def validate_screen(self, elements: str, timeout: int = 30, rule: str = 'any', event_name: Optional[str] = None) -> None:
diff --git a/optics_framework/common/config_handler.py b/optics_framework/common/config_handler.py
index 65347232..b9d14a98 100644
--- a/optics_framework/common/config_handler.py
+++ b/optics_framework/common/config_handler.py
@@ -51,6 +51,7 @@ class Config(BaseModel):
     project_path: Optional[str] = None
     include: Optional[List[str]] = None
     exclude: Optional[List[str]] = None
+    event_attributes_json: Optional[str] = None
     halt_duration: float = 0.1
     max_attempts: int = 3
 
diff --git a/optics_framework/common/eventSDK.py b/optics_framework/common/eventSDK.py
new file mode 100644
index 00000000..dc0a6cfd
--- /dev/null
+++ b/optics_framework/common/eventSDK.py
@@ -0,0 +1,240 @@
+from datetime import datetime, timezone, timedelta
+from optics_framework.common.config_handler import ConfigHandler
+from optics_framework.common.logging_config import internal_logger
+from optics_framework.common.runner.printers import TreeResultPrinter
+from optics_framework.common import test_context
+import json
+import os
+import threading
+import requests
+
+class EventSDK:
+    _instance = None
+
+    @classmethod
+    def get_instance(cls):
+        if cls._instance is None:
+            cls._instance = super(EventSDK, cls).__new__(cls)
+            cls._instance.__init__()
+        return cls._instance
+
+    def __init__(self):
+        if not hasattr(self, 'initialized'):
+            self.initialized = True
+            self.config_handler = ConfigHandler.get_instance()
+            self.event_attributes_json = self.config_handler.get('event_attributes_json')
+            self.all_events = []
+            self.real_time = False
+
+    def get_current_time_for_events(self):
+        try:
+            current_utc_time = datetime.now(timezone.utc)
+            desired_timezone = timezone(timedelta(hours=5, minutes=30))
+            current_time_in_desired_timezone = current_utc_time.astimezone(desired_timezone)
+            formatted_time = current_time_in_desired_timezone.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
+            return formatted_time[:-2] + ":" + formatted_time[-2:]
+        except Exception as e:
+            internal_logger.error('Unable to get current time', exc_info=e)
+            return None
+
+    def check_file_availability(self, file_path):
+        try:
+            return os.path.exists(file_path)
+        except Exception as e:
+            internal_logger.error("Unable to check file availability", exc_info=e)
+
+    def get_json_attribute(self, key):
+        try:
+            with open(self.event_attributes_json, 'r') as f:
+                data = json.load(f)
+                return data.get(key) if key in data else None
+        except Exception as e:
+            internal_logger.error(f"Unable to get {key} from JSON file", exc_info=e)
+
+    def get_mozark_event_attributes(self, file_path):
+        try:
+            with open(file_path, 'r') as f:
+                data = json.load(f)
+            application_name = self.get_application_name()
+            app_name = {"applicationName" : application_name}
+            mozark_event_attributes = {'mozarkEventAttributes': data}
+            mozark_event_attributes['mozarkEventAttributes'].update(app_name)
+            return mozark_event_attributes
+        except Exception as e:
+            internal_logger.error("Unable to get data from Mozark event JSON", exc_info=e)
+
+    def set_event_name(self, event_name):
+        return event_name
+
+    def form_event_name(self, event_name):
+        try:
+            return {'eventName': self.set_event_name(event_name)}
+        except Exception as e:
+            internal_logger.error("Unable to form event name", exc_info=e)
+
+    def form_event_attributes(self, event_attributes):
+        try:
+            current_time = self.get_current_time_for_events()
+            event_attributes['dateTime'] = current_time
+            internal_logger.debug(f"Formed event attributes: {event_attributes}")
+            return {'eventAttributes': event_attributes}
+        except Exception as e:
+            internal_logger.error("Unable to form event attributes", exc_info=e)
+
+    def submit_single_event(self, event_name, event_attributes, real_time=False, time_interval=0):
+        try:
+            final_event_data = self.form_event_name(event_name) | self.form_event_attributes(event_attributes)
+            mozark_event_attributes = self.get_mozark_event_attributes(self.event_attributes_json)
+            event_data = {**final_event_data, **mozark_event_attributes}
+            self.all_events.append(event_data)
+            self.event_sdk_initializer(real_time, time_interval)
+        except Exception as e:
+            internal_logger.error("Unable to form final event data", exc_info=e)
+
+    def event_sdk_initializer(self, real_time, time_interval):
+        if real_time:
+            self.send_real_time_events(time_interval)
+        else:
+            self.send_events_after_execution()
+
+    def send_real_time_events(self, interval):
+        try:
+            while self.all_events:
+                threading.Timer(interval, self.send_batch_events, args=(self.all_events[:])).start()
+                self.all_events.clear()
+        except Exception as e:
+            internal_logger.error("Unable to send real-time events", exc_info=e)
+
+    def send_events_after_execution(self):
+        try:
+            while self.all_events:
+                batch = self.all_events[:5]
+                self.send_batch_events(batch)
+                self.all_events = self.all_events[5:]
+        except Exception as e:
+            internal_logger.error("Unable to send events after execution", exc_info=e)
+
+    def send_batch_events(self, event_data):
+        try:
+            if not event_data:
+                internal_logger.info("No events to send.")
+                return
+            url = self.event_attributes_json.get("eventUrl") + "/v1/event/batchevent"
+            bearer = self.event_attributes_json.get("testParameters_bearer")
+            headers = {
+                'Authorization': f'Bearer {bearer}',
+                'Content-Type': 'application/json'
+            }
+            payload = json.dumps(event_data)
+            internal_logger.debug(f"Sending event to {url}: {payload}")
+            response = requests.post(url, headers=headers, data=payload, timeout=10)
+            internal_logger.info(f"Event API response: {response.text}")
+        except Exception as e:
+            internal_logger.error("Unable to send batch events", exc_info=e)
+
+    def add_to_array(self, event_data):
+        try:
+            return json.dumps([event_data])
+        except Exception as e:
+            internal_logger.error("Unable to add events to array", exc_info=e)
+
+    def convert_to_json(self, data):
+        return json.dumps(data)
+
+    def create_events_dictionary(self, key, value):
+        return {key: value}
+
+    def nested_dictionary(self, name, key, value):
+        return {name: {key: value}}
+
+    def user_event_attributes(self, event_name,timestamp=None, **event_attributes):
+        current_time = self.get_current_time_for_events() if timestamp is None else timestamp
+        test_case_name = self.get_test_case_name()
+        application_name = self.get_application_name()
+        app_version = self.get_app_version()
+        event_attributes['dateTime'] = current_time
+        event_attributes['testCaseName'] = test_case_name
+        event_attributes['applicationName'] = application_name
+        event_attributes['applicationVersion'] = app_version
+        return {'eventName': event_name, 'eventAttributes': event_attributes}
+
+    def mozark_event_attributes(self, **event_attributes):
+        return {'mozarkEventAttributes': event_attributes}
+
+    def merge_dictionaries(self, dict1, dict2):
+        return {**dict1, **dict2}
+
+    def merge_nested_dictionaries(self, name, dict1, dict2):
+        dict2[name].update(dict1)
+        return dict2
+
+    def print_event(self, event_data):
+        printer = TreeResultPrinter.get_instance()
+        printer.print_event_log(event_data)
+
+    def capture_event(self, event_name, **args):
+        """
+        Captures events by forming the required attributes and sending them in a batch.
+
+        Args:
+            event_name (str): The name of the event.
+            *args: Key-value pairs as strings in the format 'key=value'.
+
+        Returns:
+            None
+        """
+        try:
+            user_event_attrb = self.user_event_attributes(event_name=event_name, **args)
+            # Capture and process the events
+            file_path = self.event_attributes_json
+            if not file_path:
+                raise ValueError("Event attributes JSON file path is not set.")
+
+            mozark_event_attributes = self.get_mozark_event_attributes(file_path)
+
+            combined_dict = self.merge_dictionaries(user_event_attrb, mozark_event_attributes)
+            #print event to console
+            self.print_event(combined_dict)
+            self.all_events.append(combined_dict)
+
+        except Exception as e:
+            internal_logger.error("Unable to capture events", exc_info=e)
+
+    def capture_event_with_time_input(self, event_name, current_time, **event_attributes):
+        """
+        Captures events by forming the required attributes and sending them in a batch.
+
+        Args:
+            event_name (str): The name of the event.
+            *args: Key-value pairs as strings in the format 'key=value'.
+
+        Returns:
+            None
+        """
+        try:
+            user_event_attrb = self.user_event_attributes(event_name=event_name,timestamp=current_time, **event_attributes)
+            # Capture and process the events
+            file_path = self.event_attributes_json
+            if not file_path:
+                raise ValueError("Environment variable 'ATTRIBUTES_JSON' is not set.")
+            mozark_event_attributes = self.get_mozark_event_attributes(file_path)
+            combined_dict = self.merge_dictionaries(user_event_attrb, mozark_event_attributes)
+            #print event to console
+            self.print_event(combined_dict)
+            self.all_events.append(combined_dict)
+
+        except Exception as e:
+            internal_logger.error("Unable to capture events", exc_info=e)
+
+    def send_all_events(self):
+        self.send_batch_events(self.all_events)
+
+
+    def get_test_case_name(self):
+        return test_context.current_test_case.get()
+
+    def get_application_name(self):
+        return self.get_json_attribute("applicationName")
+
+    def get_app_version(self):
+        return self.get_json_attribute("appVersion")
diff --git a/optics_framework/common/execution.py b/optics_framework/common/execution.py
index 37e1777e..8a1be82b 100644
--- a/optics_framework/common/execution.py
+++ b/optics_framework/common/execution.py
@@ -167,7 +167,7 @@ def create_runner(
         verifier = session.optics.build(Verifier)
 
         if runner_type == "test_runner":
-            result_printer = TreeResultPrinter(
+            result_printer = TreeResultPrinter.get_instance(
                 TerminalWidthProvider()) if use_printer else NullResultPrinter()
             runner = TestRunner(
                 test_cases, modules, elements, {}, result_printer, session_id=session.session_id
diff --git a/optics_framework/common/runner/printers.py b/optics_framework/common/runner/printers.py
index 5625e145..7d9b7093 100644
--- a/optics_framework/common/runner/printers.py
+++ b/optics_framework/common/runner/printers.py
@@ -8,6 +8,7 @@
 from rich.progress import Progress, TaskID
 from rich.console import Console, Group
 import shutil
+import json
 
 
 class TestCaseResult(BaseModel):
@@ -50,6 +51,10 @@ def test_state(self, value: Dict[str, TestCaseResult]) -> None:
     def print_tree_log(self, test_case_result: TestCaseResult) -> None:
         pass
 
+    @abc.abstractmethod
+    def print_event_log(self, event_data: dict) -> None:
+        pass
+
     @abc.abstractmethod
     def start_live(self) -> None:
         pass
@@ -88,6 +93,12 @@ def print_tree_log(self, test_case_result: TestCaseResult) -> None:
         #   - No logging or output is generated, as per the null object pattern.
         pass
 
+    def print_event_log(self, event_data: dict) -> None:
+        # This method does nothing because:
+        #   - It’s part of the no-op behavior.
+        #   - No event logging or output is generated.
+        pass
+
     def start_live(self) -> None:
         # This method does nothing because:
         #   - It’s part of the no-op behavior.
@@ -116,6 +127,7 @@ def get_terminal_width(self, default: int = 80) -> int:
 
 
 class TreeResultPrinter(IResultPrinter):
+    _instance: "TreeResultPrinter" = None
     STATUS_COLORS: Dict[str, str] = {
         "NOT RUN": "grey50",
         "RUNNING": "yellow",
@@ -128,11 +140,22 @@ class TreeResultPrinter(IResultPrinter):
     }
 
     def __init__(self, terminal_width_provider: TerminalWidthProvider) -> None:
+        if hasattr(self, "_initialized") and self._initialized:
+            return
         self.terminal_width_provider = terminal_width_provider
         self._live: Live | None = None
         self._test_state: Dict[str, TestCaseResult] = {}
         self.progress = Progress()
         self.task_id: TaskID | None = None
+        self.initialized = True
+
+    @classmethod
+    def get_instance(cls, terminal_width_provider: TerminalWidthProvider = None):
+        if cls._instance is None:
+            if terminal_width_provider is None:
+                raise ValueError("First call to get_instance() must include terminal_width_provider.")
+            cls._instance = cls(terminal_width_provider)
+        return cls._instance
 
     @property
     def test_state(self) -> Dict[str, TestCaseResult]:
@@ -197,6 +220,14 @@ def print_tree_log(self, test_case_result: TestCaseResult) -> None:
         if self._live:
             self._live.update(self._render_tree())
 
+    def print_event_log(self, event_data: dict) -> None:
+        if not self._live:
+            print(event_data)
+            return
+        formatted = json.dumps(event_data, indent=2)
+        event_panel = Panel(Text(formatted, style="yellow"), title="Event Captured", border_style="bright_blue")
+        self._live.console.print(event_panel)
+
     def start_live(self) -> None:
         if not self._live:
             self._live = Live(self._render_tree(
diff --git a/optics_framework/common/runner/test_runnner.py b/optics_framework/common/runner/test_runnner.py
index cddb43c2..680764e5 100644
--- a/optics_framework/common/runner/test_runnner.py
+++ b/optics_framework/common/runner/test_runnner.py
@@ -10,6 +10,7 @@
 from optics_framework.common.session_manager import Session
 from optics_framework.common.config_handler import ConfigHandler
 from optics_framework.common.logging_config import internal_logger
+from optics_framework.common import test_context
 from optics_framework.common.runner.printers import IResultPrinter, TestCaseResult, NullResultPrinter
 from optics_framework.common.models import TestCaseNode, ModuleNode, KeywordNode, State, Node
 from optics_framework.common.events import get_event_manager, EventStatus, CommandType, Event
@@ -149,6 +150,7 @@ def resolve_param(self, param: str) -> str:
         return resolved_value
 
     def _init_test_case(self, test_case: str) -> TestCaseResult:
+        test_context.current_test_case.set(test_case)
         return self.result_printer.test_state.get(test_case, TestCaseResult(
             id=str(uuid.uuid4()),
             name=test_case,
diff --git a/optics_framework/common/test_context.py b/optics_framework/common/test_context.py
new file mode 100644
index 00000000..c4e8e2b3
--- /dev/null
+++ b/optics_framework/common/test_context.py
@@ -0,0 +1,3 @@
+from contextvars import ContextVar
+
+current_test_case: ContextVar[str] = ContextVar("current_test_case", default=None)
diff --git a/optics_framework/common/utils.py b/optics_framework/common/utils.py
index 4af962c6..a4779267 100644
--- a/optics_framework/common/utils.py
+++ b/optics_framework/common/utils.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timezone, timedelta
+from datetime import datetime
 import hashlib
 from fuzzywuzzy import fuzz
 import re
@@ -6,9 +6,18 @@
 import cv2
 import base64
 import numpy as np
+from enum import Enum
 from optics_framework.common.logging_config import internal_logger
 from optics_framework.common.config_handler import ConfigHandler
 
+
+class SpecialKey(Enum):
+    ENTER = 'enter'
+    TAB = 'tab'
+    BACKSPACE = 'backspace'
+    SPACE = 'space'
+    ESCAPE = 'escape'
+
 def determine_element_type(element):
     # Check if the input is an Image path
     if element.split(".")[-1] in ["jpg", "jpeg", "png", "bmp"]:
@@ -23,17 +32,6 @@ def determine_element_type(element):
     return "Text"
 
 
-def get_current_time_for_events():
-    try:
-        current_utc_time = datetime.now(timezone.utc)
-        desired_timezone = timezone(timedelta(hours=5, minutes=30))
-        current_time_in_desired_timezone = current_utc_time.astimezone(desired_timezone)
-        formatted_time = current_time_in_desired_timezone.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
-        return formatted_time[:-2] + ":" + formatted_time[-2:]
-    except Exception as e:
-        internal_logger.error('Unable to get current time', exc_info=e)
-        return None
-
 def encode_numpy_to_base64(image: np.ndarray) -> str:
     """
     Encodes a NumPy image (OpenCV format) to a base64 string.
diff --git a/optics_framework/engines/drivers/appium.py b/optics_framework/engines/drivers/appium.py
index 622680f3..7b8214c0 100644
--- a/optics_framework/engines/drivers/appium.py
+++ b/optics_framework/engines/drivers/appium.py
@@ -10,6 +10,7 @@
 from optics_framework.common import utils
 from optics_framework.common.utils import SpecialKey
 from optics_framework.engines.drivers.appium_driver_manager import set_appium_driver
+from optics_framework.common.eventSDK import EventSDK
 from optics_framework.engines.drivers.appium_UI_helper import UIHelper
 from pydantic import BaseModel, Field, model_validator, ValidationError
 from typing import Union
@@ -51,6 +52,7 @@ def __init__(self):
         if hasattr(self, "initialized") and self.initialized:
             return
         self.driver = None
+        self.event_sdk = EventSDK.get_instance()
         config_handler = ConfigHandler.get_instance()
         config: Optional[Dict[str, Any]] = config_handler.get_dependency_config(self.DEPENDENCY_TYPE, self.NAME)
 
@@ -70,7 +72,7 @@ def __init__(self):
         self.initialized = True
 
     # session management
-    def start_session(self):
+    def start_session(self, event_name: str | None = None) -> webdriver.Remote:
         """Start the Appium session if not already started."""
         cap = self.capabilities_model
         app_package = cap.app_package
@@ -128,6 +130,8 @@ def start_session(self):
 
         try:
             if self.driver is None:
+                if event_name:
+                    self.event_sdk.capture_event(event_name)
                 self.driver = webdriver.Remote(self.appium_server_url, options=options)
                 set_appium_driver(self.driver)
                 self.ui_helper = UIHelper()
@@ -136,9 +140,11 @@ def start_session(self):
             internal_logger.debug(f"Failed to start Appium session: {e}")
             raise
 
-    def terminate(self):
+    def terminate(self, event_name: str | None = None) -> None:
         """End the Appium session if active."""
         if self.driver:
+            if event_name:
+                self.event_sdk.capture_event(event_name)
             self.driver.quit()
             self.driver = None
 
@@ -169,7 +175,7 @@ def initialise_setup(self) -> None:
     def launch_app(self, event_name: str | None = None) -> None:
         """Launch the app using the Appium driver."""
         if self.driver is None:
-            self.start_session()
+            self.start_session(event_name)
         internal_logger.debug(f"Launched application with event: {event_name}")
 
     def get_driver(self):
@@ -181,24 +187,24 @@ def click_element(self, element, event_name=None):
         """
         Click on the specified element using Appium's click method.
         """
-        timestamp = utils.get_current_time_for_events()
+        timestamp = self.event_sdk.get_current_time_for_events()
         try:
             element.click()
             if event_name:
-                # TODO: Trigger event
+                self.event_sdk.capture_event_with_time_input(event_name, timestamp)
                 internal_logger.debug(f"Clicked on element: {element} at {timestamp}")
         except Exception as e:
             internal_logger.debug(e)
 
     def tap_at_coordinates(self, x, y, event_name=None) -> None:
         """
-        Uses Appium 2.x's mobile: clickGesture to tap at specific coordinates.
+        Simulates a tap gesture at the specified screen coordinates using Appium's `tap` method.
         """
         try:
+            timestamp = self.event_sdk.get_current_time_for_events()
             self.driver.tap([(x, y)], 100)
             if event_name:
-                # TODO: Trigger event
-                pass
+                self.event_sdk.capture_event_with_time_input(event_name, timestamp)
             internal_logger.debug(f"Tapped at coordinates ({x}, {y})")
         except Exception as e:
             internal_logger.debug(f"Failed to tap at ({x}, {y}): {e}")
@@ -228,7 +234,13 @@ def swipe(self, start_x, start_y,direction, swipe_length, event_name=None):
             internal_logger.debug(f'type of swipe_length: {type(swipe_length)}, type of start_x: {type(start_x)}')
             end_x = start_x + swipe_length
             end_y = start_y
-        self.driver.swipe(start_x, start_y, end_x, end_y, 1000)
+        timestamp = self.event_sdk.get_current_time_for_events()
+        try:
+            self.driver.swipe(start_x, start_y, end_x, end_y, 1000)
+            if event_name:
+                self.event_sdk.capture_event_with_time_input(event_name, timestamp)
+        except Exception as e:
+            internal_logger.debug(f"Failed to swipe from ({start_x}, {start_y}) to ({end_x}, {end_y}): {e}")
 
     def swipe_percentage(self, x_percentage, y_percentage, direction, swipe_percentage, event_name=None):
         window_size = self.driver.get_window_size()
@@ -240,7 +252,7 @@ def swipe_percentage(self, x_percentage, y_percentage, direction, swipe_percenta
             swipe_length = int(height * swipe_percentage / 100)
         elif direction == "left" or direction == "right":
             swipe_length = int(width * swipe_percentage / 100)
-        self.swipe(start_x, start_y, direction, swipe_length, 1000)
+        self.swipe(start_x, start_y, direction, swipe_length, event_name)
 
     def swipe_element(self, element, direction, swipe_length, event_name=None):
         location = element.location
@@ -254,10 +266,16 @@ def swipe_element(self, element, direction, swipe_length, event_name=None):
         elif direction == "left" or direction == "right":
             end_y = start_y
             end_x = start_x + swipe_length if direction == "right" else start_x - swipe_length
+        timestamp = self.event_sdk.get_current_time_for_events()
+        try:
+            self.driver.swipe(start_x, start_y, end_x, end_y, 1000)
+            if event_name:
+                self.event_sdk.capture_event_with_time_input(event_name, timestamp)
+                internal_logger.debug(f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y})")
+        except Exception as e:
+            internal_logger.debug(f"Failed to swipe from ({start_x}, {start_y}) to ({end_x}, {end_y}): {e}")
 
-        self.driver.swipe(start_x, start_y, end_x, end_y, 1000)
-
-    def scroll(self, direction, duration=1000, even_name=None):
+    def scroll(self, direction, duration=1000, event_name=None):
         window_size = self.driver.get_window_size()
         width = window_size['width']
         height = window_size['height']
@@ -265,39 +283,43 @@ def scroll(self, direction, duration=1000, even_name=None):
             start_x = width // 2
             start_y = int(height * 0.8)
             end_y = int(height * 0.2)
-            self.driver.swipe(start_x, start_y, start_x, end_y, duration)
         elif direction == "down":
             start_x = width // 2
             start_y = int(height * 0.2)
             end_y = int(height * 0.8)
+        timestamp = self.event_sdk.get_current_time_for_events()
+        try:
             self.driver.swipe(start_x, start_y, start_x, end_y, duration)
+            if event_name:
+                self.event_sdk.capture_event_with_time_input(event_name, timestamp)
+                internal_logger.debug(f"Scrolled {direction} from ({start_x}, {start_y}) to ({start_x}, {end_y})")
+        except Exception as e:
+            internal_logger.debug(f"Failed to scroll {direction}: {e}")
 
 
     def enter_text_element(self, element, text, event_name=None):
         if event_name:
-            #TODO: Trigger event
-            pass
+            self.event_sdk.capture_event(event_name)
         element.send_keys(text)
 
     def clear_text_element(self, element, event_name=None):
         if event_name:
-            # TODO: Trigger event
-            pass
+            self.event_sdk.capture_event(event_name)
         element.clear()
 
     def enter_text(self, text, event_name=None):
         if event_name:
-            #TODO: Trigger event
-            pass
+            self.event_sdk.capture_event(event_name)
         self.driver.execute_script("mobile: type", {"text": text})
 
     def clear_text(self, event_name=None):
         if event_name:
-            # TODO: Trigger event
-            pass
+            self.event_sdk.capture_event(event_name)
         self.driver.execute_script("mobile: clear")
 
-    def press_keycode(self,keycode, even_name=None):
+    def press_keycode(self,keycode, event_name=None):
+        if event_name:
+            self.event_sdk.capture_event(event_name)
         self.driver.press_keycode(keycode)
 
     def enter_text_using_keyboard(self, input_value: Union[str, SpecialKey], event_name=None):
@@ -311,9 +333,14 @@ def enter_text_using_keyboard(self, input_value: Union[str, SpecialKey], event_n
         try:
             if isinstance(input_value, SpecialKey):
                 internal_logger.debug(f"Pressing Detected SpecialKey: {input_value}. Keycode: {keycode_map[input_value]}")
+                timestamp = self.event_sdk.get_current_time_for_events()
                 self.driver.press_keycode(keycode_map[input_value])
             else:
+                timestamp = self.event_sdk.get_current_time_for_events()
                 self.driver.execute_script("mobile: type", {"text": input_value})
+            if event_name:
+                self.event_sdk.capture_event_with_time_input(event_name, timestamp)
+                internal_logger.debug(f"Pressed key: {input_value} at {timestamp}")
         except Exception as e:
             raise RuntimeError(f"Appium failed to enter input: {e}")
 
@@ -358,13 +385,13 @@ def pixel_2_appium(self, x, y, screenshot):
     def press_element(self, element,repeat, event_name=None):
         for _ in range(repeat):
             try:
-                timestamp = utils.get_current_time_for_events()
+                timestamp = self.event_sdk.get_current_time_for_events()
                 element.click()
             except Exception as e:
                 raise Exception(f"Error occurred while clicking on element: {e}")
-            if event_name:
-                # trigger event
-                internal_logger.debug(f"Clicked on element: {element} at {timestamp}")
+        if event_name:
+            self.event_sdk.capture_event_with_time_input(event_name, timestamp)
+            internal_logger.debug(f"Clicked on element: {element} at {timestamp}")
 
     def press_coordinates(self, x, y, repeat, event_name=None):
         """
diff --git a/optics_framework/engines/drivers/selenium.py b/optics_framework/engines/drivers/selenium.py
index 46d9963e..1ec52bfa 100644
--- a/optics_framework/engines/drivers/selenium.py
+++ b/optics_framework/engines/drivers/selenium.py
@@ -7,6 +7,7 @@
 from optics_framework.common.driver_interface import DriverInterface
 from optics_framework.common.config_handler import ConfigHandler
 from optics_framework.common.logging_config import internal_logger
+from optics_framework.common.eventSDK import EventSDK
 from optics_framework.engines.drivers.selenium_driver_manager import set_selenium_driver
 from optics_framework.engines.drivers.selenium_UI_helper import UIHelper
 from pydantic import BaseModel, Field,ValidationError, ConfigDict
@@ -56,11 +57,12 @@ def __init__(self):
         self.selenium_server_url = parsed_config.url
         self.capabilities = parsed_config.capabilities
         self.browser_url = parsed_config.capabilities.browser_url
+        self.eventSDK = EventSDK.get_instance()
         self.initialized = True
         self.ui_helper = None
 
 
-    def start_session(self):
+    def start_session(self, event_name: str | None = None) -> webdriver.Remote:
         """Start a new Selenium session with the specified browser."""
         if self.driver is None:
             browser_name = self.capabilities.browser_name.lower()
@@ -81,6 +83,10 @@ def start_session(self):
                 )
 
                 set_selenium_driver(self.driver)
+                if event_name:
+                    internal_logger.debug(
+                        f"Starting Selenium session with event: {event_name}")
+                    self.eventSDK.capture_event(event_name)
                 self.ui_helper = UIHelper()
                 internal_logger.debug(
                     f"Started Selenium session at {self.selenium_server_url} with browser: {browser_name}")
@@ -90,12 +96,16 @@ def start_session(self):
                 raise
         return self.driver
 
-    def terminate(self):
+    def terminate(self, event_name: str | None = None) -> None:
         """End the current Selenium session."""
         if self.driver is not None:
             try:
                 self.driver.quit()
                 internal_logger.debug("Selenium session ended")
+                if event_name:
+                    internal_logger.debug(
+                        f"Ending Selenium session with event: {event_name}")
+                    self.eventSDK.capture_event(event_name)
             except Exception as e:
                 internal_logger.error(f"Failed to end Selenium session: {e}")
             finally:
@@ -107,6 +117,10 @@ def launch_app(self, event_name: str | None = None) -> None:
             self.start_session()
         try:
             self.driver.get(self.browser_url)
+            if event_name:
+                internal_logger.debug(
+                    f"Launching app at {self.browser_url} with event: {event_name}")
+                self.eventSDK.capture_event(event_name)
             internal_logger.debug(
                 f"Launched web app at {self.browser_url} with event: {event_name}")
         except Exception as e:
@@ -125,9 +139,12 @@ def press_element(self, element, repeat: int = 1, event_name: str | None = None)
             """
         try:
             for _ in range(repeat):
+                timestamp = self.eventSDK.get_current_time_for_events()
                 element.click()
             internal_logger.debug(
                 f"Pressed element {repeat} times with event: {event_name}")
+            if event_name:
+                self.eventSDK.capture_event_with_time_input(event_name, timestamp)
         except Exception as e:
             internal_logger.error(f"Failed to press element: {e}")
             raise
@@ -144,7 +161,10 @@ def press_coordinates(self, coor_x: int, coor_y: int, event_name: str | None = N
             var element = document.elementFromPoint({coor_x}, {coor_y});
             if (element) element.click();
             """
+            timestamp = self.eventSDK.get_current_time_for_events()
             self.driver.execute_script(script)
+            if event_name:
+                self.eventSDK.capture_event_with_time_input(event_name, timestamp)
             internal_logger.debug(f"Clicked at coordinates ({coor_x}, {coor_y}) with event: {event_name}")
         except Exception as e:
             internal_logger.error(f"Failed to click at coordinates: {e}")
@@ -167,9 +187,13 @@ def enter_text(self, text: str, event_name: str | None = None) -> None:
         active_element = self.driver.switch_to.active_element
         if active_element:
             if text == "KEYS.ENTER":
+                timestamp = self.eventSDK.get_current_time_for_events()
                 active_element.send_keys(Keys.ENTER)
             else:
+                timestamp = self.eventSDK.get_current_time_for_events()
                 active_element.send_keys(text)
+            if event_name:
+                self.eventSDK.capture_event_with_time_input(event_name, timestamp)
             internal_logger.debug(f"Typed '{text}' into active element with event: {event_name}")
         else:
             internal_logger.error("No active element to type into.")
@@ -182,9 +206,12 @@ def enter_text_element(self, element, text: str, event_name: str | None = None)
                 "Selenium session not started. Call start_session() first.")
         try:
             element.clear()  # Clear existing text first
+            timestamp = self.eventSDK.get_current_time_for_events()
             element.send_keys(text)
             internal_logger.debug(
                 f"Entered text '{text}' into element with event: {event_name}")
+            if event_name:
+                self.eventSDK.capture_event_with_time_input(event_name, timestamp)
         except Exception as e:
             internal_logger.error(f"Failed to enter text into element: {e}")
             raise
@@ -204,10 +231,13 @@ def enter_text_using_keyboard(self, input_value: Union[str, SpecialKey], event_n
 
         try:
             active_element = self.driver.switch_to.active_element
+            timestamp = self.eventSDK.get_current_time_for_events()
             if isinstance(input_value, SpecialKey):
                 active_element.send_keys(key_map[input_value])
             else:
                 active_element.send_keys(input_value)
+            if event_name:
+                self.eventSDK.capture_event_with_time_input(event_name, timestamp)
         except Exception as e:
             internal_logger.error(f"Failed to enter input using keyboard: {e}")
             raise RuntimeError(f"Selenium failed to enter input: {e}")
@@ -216,6 +246,8 @@ def clear_text(self, event_name: str | None = None) -> None:
         """Clear the active element (if possible)."""
         try:
             active_element = self.driver.switch_to.active_element
+            if event_name:
+                self.eventSDK.capture_event(event_name)
             active_element.clear()
             internal_logger.debug(f"Cleared text in active element with event: {event_name}")
         except Exception as e:
@@ -225,6 +257,8 @@ def clear_text(self, event_name: str | None = None) -> None:
 
     def clear_text_element(self, element, event_name: str | None = None) -> None:
         try:
+            if event_name:
+                self.eventSDK.capture_event(event_name)
             element.clear()
             internal_logger.debug(f"Cleared text in specified element with event: {event_name}")
         except Exception as e:
@@ -243,6 +277,8 @@ def swipe_element(self, element, direction: str, swipe_length: int, event_name:
 
     def scroll(self, direction: str, duration: int = 1000, event_name: str | None = None) -> None:
         try:
+            if event_name:
+                self.eventSDK.capture_event(event_name)
             if direction == "down":
                 self.driver.execute_script("window.scrollBy(0, window.innerHeight);")
             elif direction == "up":


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request integrates eventSDK into multiple driver modules and the verifier to enable event triggers during runtime, with updates that include reading event attributes from configuration files and propagating event information across different driver actions.  
- Introduces event capture mechanisms in Selenium, Appium, and verifier modules.  
- Adds support for test context and event attributes configuration.  
- Updates common utilities and eventSDK functionality to support real-time and post-execution event submissions.

### Reviewed Changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| optics_framework/engines/drivers/selenium.py | Integrates eventSDK in Selenium driver with event trigger options. |
| optics_framework/engines/drivers/appium.py | Adds event capture to Appium driver actions and updates method calls. |
| optics_framework/common/utils.py | Removes the old get_current_time_for_events utility in favor of eventSDK’s method. |
| optics_framework/common/test_context.py | Introduces a context variable for sharing the current test case. |
| optics_framework/common/runner/test_runnner.py | Initializes test context during test execution. |
| optics_framework/common/eventSDK.py | Implements the eventSDK with functions for event composition and submission. |
| optics_framework/common/config_handler.py | Adds configuration support for event attributes. |
| optics_framework/api/verifier.py | Integrates event triggering in element presence validations. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**optics_framework/engines/drivers/selenium.py:60**
* [nitpick] Consider renaming the attribute 'eventSDK' to 'event_sdk' for consistency with other modules (e.g., Appium) and to follow standard snake_case naming conventions.
```
self.eventSDK = EventSDK.get_instance()
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T10:06:29Z
user: login: malto101
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T07:55:36Z
user: login: malto101
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T08:24:35Z

Review comments:
diff_hunk: @@ -240,7 +252,7 @@ def swipe_percentage(self, x_percentage, y_percentage, direction, swipe_percenta
             swipe_length = int(height * swipe_percentage / 100)
         elif direction == "left" or direction == "right":
             swipe_length = int(width * swipe_percentage / 100)
-        self.swipe(start_x, start_y, direction, swipe_length, 1000)
+        self.swipe(start_x, start_y, direction, swipe_length, 1000, event_name)
path: optics_framework/engines/drivers/appium.py
user: login: Copilot
body: The swipe method is being called with an extra duration argument (1000) which is not reflected in its signature; adjust the method signature to accept a duration parameter or remove the extra argument from the call to ensure consistency.
created_at: 2025-05-19T10:06:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,263 @@
+import subprocess
+from datetime import datetime, timezone, timedelta
+from optics_framework.common.config_handler import ConfigHandler
+from optics_framework.common.logging_config import internal_logger
+from optics_framework.common import test_context
+import json
+import os
+import re
+import threading
+import requests
+
+class EventSDK:
+    _instance = None
+
+    @classmethod
+    def get_instance(cls):
+        if cls._instance is None:
+            cls._instance = super(EventSDK, cls).__new__(cls)
+            cls._instance.__init__()
+        return cls._instance
+
+    def __init__(self):
+        if not hasattr(self, 'initialized'):
+            self.initialized = True
+            self.config_handler = ConfigHandler.get_instance()
+            self.event_attributes_json = self.config_handler.get('event_attributes_json')
+            self.all_events = []
+            self.real_time = False
+
+
+    def get_current_time_for_events(self):
+        try:
+            current_utc_time = datetime.now(timezone.utc)
+            desired_timezone = timezone(timedelta(hours=5, minutes=30))
+            current_time_in_desired_timezone = current_utc_time.astimezone(desired_timezone)
+            formatted_time = current_time_in_desired_timezone.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
+            return formatted_time[:-2] + ":" + formatted_time[-2:]
+        except Exception as e:
+            internal_logger.error('Unable to get current time', exc_info=e)
+            return None
+
+    def check_file_availability(self, file_path):
+        try:
+            return os.path.exists(file_path)
+        except Exception as e:
+            internal_logger.error("Unable to check file availability", exc_info=e)
+
+    def get_json_attribute(self, key):
+        try:
+            with open(self.event_attributes_json, 'r') as f:
+                data = json.load(f)
+                return data.get(key) if key in data else None
+        except Exception as e:
+            internal_logger.error(f"Unable to get {key} from JSON file", exc_info=e)
+
+    def get_mozark_event_attributes(self, file_path):
+        try:
+            with open(file_path, 'r') as f:
+                data = json.load(f)
+            application_name = self.get_application_name()
+            app_name = {"applicationName" : application_name}
+            mozark_event_attributes = {'mozarkEventAttributes': data}
+            mozark_event_attributes['mozarkEventAttributes'].update(app_name)
+            return mozark_event_attributes
+        except Exception as e:
+            internal_logger.error("Unable to get data from Mozark event JSON", exc_info=e)
+
+    def set_event_name(self, event_name):
+        return event_name
+
+    def form_event_name(self, event_name):
+        try:
+            return {'eventName': self.set_event_name(event_name)}
+        except Exception as e:
+            internal_logger.error("Unable to form event name", exc_info=e)
+
+    def form_event_attributes(self, event_attributes):
+        try:
+            current_time = self.get_current_time_for_events()
+            event_attributes['dateTime'] = current_time
+            internal_logger.debug(f"Formed event attributes: {event_attributes}")
+            return {'eventAttributes': event_attributes}
+        except Exception as e:
+            internal_logger.error("Unable to form event attributes", exc_info=e)
+
+    def submit_single_event(self, event_name, event_attributes, real_time=False, time_interval=0):
+        try:
+            final_event_data = self.form_event_name(event_name) | self.form_event_attributes(event_attributes)
+            mozark_event_attributes = self.get_mozark_event_attributes(self.event_attributes_json)
+            event_data = {**final_event_data, **mozark_event_attributes}
+            self.all_events.append(event_data)
+            self.event_sdk_initializer(real_time, time_interval)
+        except Exception as e:
+            internal_logger.error("Unable to form final event data", exc_info=e)
+
+    def event_sdk_initializer(self, real_time, time_interval):
+        if real_time:
+            self.send_real_time_events(time_interval)
+        else:
+            self.send_events_after_execution()
+
+    def send_real_time_events(self, interval):
+        try:
+            while self.all_events:
+                threading.Timer(interval, self.send_batch_events, args=(self.all_events[:])).start()
+                self.all_events.clear()
+        except Exception as e:
+            internal_logger.error("Unable to send real-time events", exc_info=e)
+
+    def send_events_after_execution(self):
+        try:
+            while self.all_events:
+                batch = self.all_events[:5]
+                self.send_batch_events(batch)
+                self.all_events = self.all_events[5:]
+        except Exception as e:
+            internal_logger.error("Unable to send events after execution", exc_info=e)
+
+    def send_batch_events(self, event_data):
+        try:
+            if not event_data:
+                internal_logger.info("No events to send.")
+                return
+            url = self.event_attributes_json.get("eventUrl") + "/v1/event/batchevent"
+            bearer = self.event_attributes_json.get("testParameters_bearer")
+            headers = {
+                'Authorization': f'Bearer {bearer}',
+                'Content-Type': 'application/json'
+            }
+            payload = json.dumps(event_data)
+            internal_logger.debug(f"Sending event to {url}: {payload}")
+            response = requests.post(url, headers=headers, data=payload)
+            internal_logger.info(f"Event API response: {response.text}")
+        except Exception as e:
+            internal_logger.error("Unable to send batch events", exc_info=e)
+
+    def add_to_array(self, event_data):
+        try:
+            return json.dumps([event_data])
+        except Exception as e:
+            internal_logger.error("Unable to add events to array", exc_info=e)
+
+    def convert_to_json(self, data):
+        return json.dumps(data)
+
+    def create_events_dictionary(self, key, value):
+        return {key: value}
+
+    def nested_dictionary(self, name, key, value):
+        return {name: {key: value}}
+
+    def user_event_attributes(self, event_name,timestamp=None, **event_attributes):
+        current_time = self.get_current_time_for_events() if timestamp is None else timestamp
+        testCaseName = self.get_test_case_name()
+        application_name = self.get_application_name()
+        app_version = self.get_app_version()
+        event_attributes['dateTime'] = current_time
+        event_attributes['testCaseName'] = testCaseName
+        event_attributes['applicationName'] = application_name
+        event_attributes['applicationVersion'] = app_version
+        return {'eventName': event_name, 'eventAttributes': event_attributes}
+
+    def mozark_event_attributes(self, **event_attributes):
+        return {'mozarkEventAttributes': event_attributes}
+
+    def file_exists_local(self, cmd):
+        return subprocess.getstatusoutput(cmd)
+
+    def merge_dictionaries(self, dict1, dict2):
+        return {**dict1, **dict2}
+
+    def merge_nested_dictionaries(self, name, dict1, dict2):
+        dict2[name].update(dict1)
+        return dict2
+
+
+    def capture_event(self, event_name, **args):
+        """
+        Captures events by forming the required attributes and sending them in a batch.
+
+        Args:
+            event_name (str): The name of the event.
+            *args: Key-value pairs as strings in the format 'key=value'.
+
+        Returns:
+            None
+        """
+        try:
+            user_event_attrb = self.user_event_attributes(event_name=event_name, **args)
+            # Capture and process the events
+            file_path = self.event_attributes_json
+            if not file_path:
+                raise ValueError("Environment variable 'ATTRIBUTES_JSON' is not set.")
+
+            mozark_event_attributes = self.get_mozark_event_attributes(file_path)
+
+            combined_dict = self.merge_dictionaries(user_event_attrb, mozark_event_attributes)
+            self.all_events.append(combined_dict)
+
+        except Exception as e:
+            self.logger.error("Unable to capture events", exc_info=e)
path: optics_framework/common/eventSDK.py
user: login: Copilot
body: Replace 'self.logger.error' with 'internal_logger.error' in the capture_event methods to ensure consistent logging behavior, as 'internal_logger' is the imported logger used elsewhere.
```suggestion
            internal_logger.error("Unable to capture events", exc_info=e)
```
created_at: 2025-05-19T10:06:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -128,11 +134,22 @@ class TreeResultPrinter(IResultPrinter):
     }
 
     def __init__(self, terminal_width_provider: TerminalWidthProvider) -> None:
+        if hasattr(self, "_initialized") and self._initialized:
path: optics_framework/common/runner/printers.py
user: login: malto101
body: This breaks NullResultPrinter,
add an empty method for it
created_at: 2025-05-20T07:55:35Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-20T08:05:38Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=mozarkai_optics-framework&pullRequest=55) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=mozarkai_optics-framework&pullRequest=55&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=mozarkai_optics-framework&pullRequest=55&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=mozarkai_optics-framework&pullRequest=55&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=mozarkai_optics-framework&pullRequest=55&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=mozarkai_optics-framework&pullRequest=55&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=mozarkai_optics-framework&pullRequest=55)


author_association: NONE