Skip to content

Commit 5f6f888

Browse files
Kaustubh22327rohitesh-wingify
authored andcommitted
feat: add support for vwo_feTrackUsage event
1 parent a5b4adf commit 5f6f888

8 files changed

Lines changed: 149 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.55.0] - 2026-06-16
9+
10+
### Added
11+
12+
- Added support for FE Usage Tracking. The SDK now accurately tracks usage for features by firing a new usage tracking event (`vwo_feTrackUsage`) for storage hits, feature-not-found cases, and cached holdouts etc.
13+
814
## [1.50.0] - 2026-05-28
915

1016
This release introduces **Wingify** as the primary SDK branding and package namespace, while keeping existing **VWO** integrations fully supported.

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def run(self):
143143

144144
setup(
145145
name=_brand["name"],
146-
version="1.50.0",
146+
version="1.55.0",
147147
description=_brand["description"],
148148
long_description=long_description,
149149
long_description_content_type="text/markdown",

wingify/api/get_flag_api.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from ..utils.impression_util import (
3737
send_impression_for_variation_shown_batch,
3838
send_impression_for_variation_shown,
39+
create_and_send_impression_for_usage_tracking,
3940
)
4041
from ..models.user.get_flag import GetFlag
4142
from ..services.storage_service import StorageService
@@ -76,6 +77,9 @@ def get(
7677
:param hook_manager: The hook manager to execute hooks.
7778
"""
7879

80+
# Flag for usage tracking - false if no variation shown call is sent
81+
is_variation_shown_fired = False
82+
7983
feature = get_feature_from_key(settings, feature_key)
8084
is_enabled = False
8185
variables = []
@@ -173,6 +177,15 @@ def get(
173177
)
174178
else:
175179
send_impression_for_variation_shown_batch(holdout_payloads, account_id=settings.get_account_id(), sdk_key=settings.get_sdk_key())
180+
181+
# if impression is fired for any of the holdouts, set is_variation_shown_fired to True
182+
if holdout_payloads and len(holdout_payloads) > 0:
183+
is_variation_shown_fired = True
184+
185+
# Case: User is already part of a holdout (stored decision).
186+
if settings.get_is_tracking_usage_enabled() and not is_variation_shown_fired:
187+
create_and_send_impression_for_usage_tracking(settings, feature_key, context)
188+
176189
# Return a disabled flag as user is in persistent holdout
177190
return GetFlag(is_enabled=False, variables=[], session_id=context.get_session_id(), uuid=context.get_vwo_uuid())
178191
if stored_data and stored_data.get("experimentVariationId"):
@@ -194,7 +207,19 @@ def get(
194207
)
195208
decision["isUserPartOfCampaign"] = True
196209
# send network calls for holdouts that are newly added in settings and are not present in storage
197-
send_network_calls_for_not_in_holdouts(settings, feature, context, decision, stored_data, storage_service)
210+
# Calculate the initial size of not_in_holdout_id
211+
initial_size = len(stored_data.get("notInHoldoutId") or []) if stored_data else 0
212+
# send network calls for not_in_holdout_id
213+
updated_not_in_holdouts = send_network_calls_for_not_in_holdouts(settings, feature, context, decision, stored_data, storage_service)
214+
# if variationShown event is fired, set is_variation_shown_fired to True - Optimization for newly added holdouts
215+
if updated_not_in_holdouts and len(updated_not_in_holdouts) > initial_size:
216+
is_variation_shown_fired = True
217+
218+
# Case: Feature found in storage
219+
# Send usage tracking for cached experiment decision if usage tracking is enabled
220+
if settings.get_is_tracking_usage_enabled() and not is_variation_shown_fired:
221+
create_and_send_impression_for_usage_tracking(settings, feature_key, context)
222+
198223
return GetFlag(is_enabled=True, variables=variation.get_variables(), session_id=context.get_session_id(), uuid=context.get_vwo_uuid())
199224
elif (
200225
stored_data
@@ -217,7 +242,10 @@ def get(
217242
decision["isUserPartOfCampaign"] = True
218243
# network calls for holdouts that are newly added in settings and are not present in storage
219244
# and return the updated not in holdout ids
245+
initial_size = len(stored_data.get("notInHoldoutId") or []) if stored_data else 0
220246
updated_not_in_holdout_ids = send_network_calls_for_not_in_holdouts(settings, feature, context, decision, stored_data, storage_service)
247+
if updated_not_in_holdout_ids and len(updated_not_in_holdout_ids) > initial_size:
248+
is_variation_shown_fired = True
221249
# push the updated not in holdout ids to the notInHoldoutIds array
222250
notInHoldoutIds.extend(updated_not_in_holdout_ids)
223251

@@ -236,6 +264,12 @@ def get(
236264
if feature is None:
237265
LogManager.get_instance().error_log("FEATURE_NOT_FOUND", data={"featureKey": feature_key}, debug_data = debug_event_props)
238266
is_enabled = False
267+
268+
# Case: Feature not found
269+
# If usage tracking is enabled, send usage tracking impression
270+
if settings.get_is_tracking_usage_enabled() and not is_variation_shown_fired:
271+
create_and_send_impression_for_usage_tracking(settings, feature_key, context)
272+
239273
return GetFlag(is_enabled=is_enabled, variables=variables, session_id=context.get_session_id(), uuid=context.get_vwo_uuid())
240274

241275
if context.get_session_id() is None:
@@ -315,6 +349,9 @@ def get(
315349
else:
316350
batchPayload.extend(holdout_payloads)
317351

352+
if holdout_payloads and len(holdout_payloads) > 0:
353+
is_variation_shown_fired = True
354+
318355
roll_out_rules = get_specific_rules_based_on_type(
319356
feature, CampaignTypeEnum.ROLLOUT.value
320357
)
@@ -390,9 +427,13 @@ def get(
390427
and payload is not None
391428
and len(payload) > 0
392429
):
430+
# set is_variation_shown_fired to true as the rollout impression is sent
431+
is_variation_shown_fired = True
393432
send_impression_for_variation_shown(payload, context)
394433
else:
395434
if payload is not None and len(payload) > 0:
435+
# set is_variation_shown_fired to true as the rollout impression is sent
436+
is_variation_shown_fired = True
396437
batchPayload.append(payload)
397438

398439
if not roll_out_rules:
@@ -435,9 +476,15 @@ def get(
435476
and payload is not None
436477
and len(payload) > 0
437478
):
479+
# set is_variation_shown_fired to true as the experiment impression is sent (for whitelisted user)
480+
# this prevents sending usage tracking impression for the experiment campaign when checked at the bottom
481+
is_variation_shown_fired = True
438482
send_impression_for_variation_shown(payload, context)
439483
else:
440484
if payload is not None and len(payload) > 0:
485+
# set is_variation_shown_fired to true as the experiment impression is sent (for whitelisted user)
486+
# this prevents sending usage tracking impression for the experiment campaign when checked at the bottom
487+
is_variation_shown_fired = True
441488
batchPayload.append(payload)
442489

443490
is_enabled = True
@@ -481,9 +528,13 @@ def get(
481528
and payload is not None
482529
and len(payload) > 0
483530
):
531+
# set is_variation_shown_fired to true as the experiment impression is sent.
532+
is_variation_shown_fired = True
484533
send_impression_for_variation_shown(payload, context)
485534
else:
486535
if payload is not None and len(payload) > 0:
536+
# set is_variation_shown_fired to true as the experiment impression is sent.
537+
is_variation_shown_fired = True
487538
batchPayload.append(payload)
488539

489540
if is_enabled:
@@ -545,16 +596,25 @@ def get(
545596
and payload is not None
546597
and len(payload) > 0
547598
):
599+
# set is_variation_shown_fired to true as the impact analysis impression is sent.
600+
is_variation_shown_fired = True
548601
send_impression_for_variation_shown(payload, context)
549602
else:
550603
if payload is not None and len(payload) > 0:
604+
# set is_variation_shown_fired to true as the impact analysis impression is sent.
605+
is_variation_shown_fired = True
551606
batchPayload.append(payload)
552607

553608
if not SettingsManager.get_instance().is_gateway_service_provided:
554609
send_impression_for_variation_shown_batch(
555610
batchPayload, settings.get_account_id(), settings.get_sdk_key()
556611
)
557612

613+
# Send usage tracking call when no primary variationShown event was dispatched.
614+
# If a primary event was fired, the server already has the usage tracking signal.
615+
if settings.get_is_tracking_usage_enabled() and not is_variation_shown_fired:
616+
create_and_send_impression_for_usage_tracking(settings, feature_key, context)
617+
558618
return GetFlag(is_enabled=is_enabled, variables=variables, session_id=context.get_session_id(), uuid=context.get_vwo_uuid())
559619

560620
def _update_integrations_decision_object(

wingify/constants/Constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Constants:
1717
# TODO: read from setup.py
1818
sdk_meta = {
1919
"name": "vwo-fme-python-sdk",
20-
"version": "1.50.0",
20+
"version": "1.55.0",
2121
}
2222

2323
SDK_VERSION = sdk_meta["version"]

wingify/enums/event_enum.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ class EventEnum(Enum):
2323
SDK_INIT_EVENT = "vwo_fmeSdkInit"
2424
USAGE_STATS = "vwo_sdkUsageStats"
2525
DEBUGGER_EVENT = "vwo_sdkDebug"
26+
TRACK_USAGE = "vwo_feTrackUsage"

wingify/models/settings/settings_model.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def __init__(self, data: Dict):
3636
self._poll_interval = data.get("pollInterval", Constants.POLLING_INTERVAL)
3737
self._is_web_connectivity_enabled = data.get("isWebConnectivityEnabled", True)
3838
self._holdouts = [HoldoutModel(h) for h in data.get("holdouts", [])]
39+
self._is_tracking_usage_enabled = data.get("isMAU", False)
3940

4041
# Getter methods for accessing private attributes
4142
def get_features(self) -> List[FeatureModel]:
@@ -100,4 +101,10 @@ def set_holdouts(self, value: List[HoldoutModel]):
100101
self._holdouts = value
101102

102103
def get_holdouts(self) -> List[HoldoutModel]:
103-
return self._holdouts if isinstance(self._holdouts, list) else []
104+
return self._holdouts if isinstance(self._holdouts, list) else []
105+
106+
def get_is_tracking_usage_enabled(self) -> bool:
107+
return self._is_tracking_usage_enabled
108+
109+
def set_is_tracking_usage_enabled(self, value: bool):
110+
self._is_tracking_usage_enabled = value

wingify/resources/debug-messages.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616
"HOLDOUT_SEGMENTATION_FAIL": "User ID: {userId} does not qualify segmentation for Holdout: {holdoutGroupName}",
1717
"HOLDOUT_SHOULD_NOT_EXCLUDE_USER": "User ID: {userId} is not part of Holdout: {holdoutGroupName} for feature: {featureKey}",
1818
"PART_OF_HOLDOUT_IN_MEG": "MEG: User ID:{userId} is part of holdout: {holdoutId} for feature: {featureKey}",
19-
"HOLDOUT_SKIP_EVALUATION": "Skip holdout reevaluation for holdout ID: {holdoutId} because {reason}"
19+
"HOLDOUT_SKIP_EVALUATION": "Skip holdout reevaluation for holdout ID: {holdoutId} because {reason}",
20+
"IMPRESSION_FOR_TRACK_USAGE": "Impression built for vwo_feTrackUsage({brand} standard event for tracking usage) event having Account ID:{accountId}, User ID:{userId}, and feature key:{featureKey}"
2021
}

wingify/utils/impression_util.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,72 @@ def send_batch_request():
8080
network_instance.execute_in_background(send_batch_request)
8181
else:
8282
# execute the request immediately
83-
send_batch_request()
83+
send_batch_request()
84+
85+
86+
def create_and_send_impression_for_usage_tracking(
87+
settings, feature_key: str, context: ContextModel
88+
):
89+
"""
90+
Creates and sends an impression for usage tracking.
91+
92+
This function is used to send a tracking event to the server when a feature flag
93+
is evaluated but no primary `variation_shown` event is dispatched (e.g., when serving
94+
cached decisions from storage, or when the flag is not found).
95+
96+
:param settings: The settings file containing the account settings.
97+
:param feature_key: The feature key for which usage is being tracked.
98+
:param context: The ContextModel object containing user and session context.
99+
"""
100+
from ..wingify_client import WingifyClient as VWOClient
101+
from ..utils.network_util import _get_event_base_payload
102+
from ..packages.logger.core.log_manager import LogManager
103+
from ..utils.log_message_util import debug_messages
104+
from ..utils.function_util import get_current_unix_timestamp_in_millis
105+
from ..utils.brand_util import get_sdk_name
106+
from ..services.settings_manager import SettingsManager
107+
108+
user_id = context.get_id()
109+
110+
properties = _get_event_base_payload(
111+
settings=settings,
112+
user_id=user_id,
113+
event_name=EventEnum.TRACK_USAGE.value,
114+
visitor_user_agent=context.get_user_agent(),
115+
ip_address=context.get_ip_address(),
116+
)
117+
118+
if context.get_session_id() is not None and context.get_session_id() != 0:
119+
properties["d"]["sessionId"] = context.get_session_id()
120+
121+
# if uuid is provided in the context, use it, otherwise generate a new one
122+
if context.get_vwo_uuid() is not None and context.get_vwo_uuid() != "":
123+
properties["d"]["msgId"] = f"{context.get_vwo_uuid()}-{get_current_unix_timestamp_in_millis()}"
124+
properties["d"]["visId"] = context.get_vwo_uuid()
125+
126+
LogManager.get_instance().debug(
127+
debug_messages.get("IMPRESSION_FOR_TRACK_USAGE").format(
128+
brand=get_sdk_name(SettingsManager.get_instance().is_via_vwo),
129+
accountId=settings.get_account_id(),
130+
userId=user_id,
131+
featureKey=feature_key
132+
)
133+
)
134+
135+
# Note: id, variation, isFirst are not set!
136+
137+
vwo_instance = VWOClient.get_instance()
138+
139+
# Get base properties for the event
140+
base_properties = get_events_base_properties(
141+
EventEnum.TRACK_USAGE.value,
142+
visitor_user_agent=context.get_user_agent(),
143+
ip_address=context.get_ip_address(),
144+
)
145+
146+
if vwo_instance.batch_event_queue is not None:
147+
# Enqueue the event to the batch queue
148+
vwo_instance.batch_event_queue.enqueue(properties)
149+
else:
150+
# Send the event immediately if batch events are not enabled
151+
send_post_api_request(base_properties, properties, user_id)

0 commit comments

Comments
 (0)