Compare commits
51 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c556ee1cf5 | ||
|
|
1858052d6b | ||
|
|
5041be1a3b | ||
|
|
6ad7bad35e | ||
|
|
d5b832f89e | ||
|
|
b77e048711 | ||
|
|
439e3fd204 | ||
|
|
e5d869d885 | ||
|
|
57031c57ab | ||
|
|
b51abc24e3 | ||
|
|
fc82943a90 | ||
|
|
2c4cf40dcb | ||
|
|
3fd46605c9 | ||
|
|
8f449bb197 | ||
|
|
31be67cddf | ||
|
|
aafb4a2739 | ||
|
|
7992ad36fd | ||
|
|
99a72e76b0 | ||
|
|
8bdf4c929f | ||
|
|
26aecca588 | ||
|
|
2f662c2a93 | ||
|
|
f7a8f04447 | ||
|
|
5700a16a94 | ||
|
|
62a9f66364 | ||
|
|
67063363a8 | ||
|
|
2b0b5d6106 | ||
|
|
64a746873b | ||
|
|
139f0e68c4 | ||
|
|
69e8fab2cc | ||
|
|
9db416dcde | ||
|
|
f28a7fbcfb | ||
|
|
6e2a772e7f | ||
|
|
dcce3569fb | ||
|
|
867247dbc0 | ||
|
|
754293e29d | ||
|
|
ddd037ce79 | ||
|
|
66f4c5c93b | ||
|
|
66e4d133ad | ||
|
|
f9f8853ab0 | ||
|
|
e0bb6fea9d | ||
|
|
4e7efaecde | ||
|
|
54a9624332 | ||
|
|
1ddffca572 | ||
|
|
c91881413a | ||
|
|
ba2d9eb81c | ||
|
|
c845415e1e | ||
|
|
ee0ede8478 | ||
|
|
72afae226b | ||
|
|
b3f545849a | ||
|
|
cd2966fb9f | ||
|
|
4b91dcded0 |
91 changed files with 1510 additions and 680 deletions
10
README_AR.md
10
README_AR.md
|
|
@ -55,17 +55,17 @@ OpenReplay هو مجموعة إعادة تشغيل الجلسة التي يمك
|
|||
## الميزات
|
||||
|
||||
- **إعادة تشغيل الجلسة:** تتيح لك إعادة تشغيل الجلسة إعادة عيش تجربة مستخدميك، ورؤية أين يواجهون صعوبة وكيف يؤثر ذلك على سلوكهم. يتم تحليل كل إعادة تشغيل للجلسة تلقائيًا بناءً على الأساليب الاستدلالية، لسهولة التقييم.
|
||||
- **أدوات التطوير (DevTools):** إنها مثل التصحيح في متصفحك الخاص. يوفر لك OpenReplay السياق الكامل (نشاط الشبكة، أخطاء JavaScript، إجراءات/حالة التخزين وأكثر من 40 مقياسًا) حتى تتمكن من إعادة إنتاج الأخطاء فورًا وفهم مشكلات الأداء.
|
||||
- **أدوات التطوير (DevTools):** إنها مثل المصحح (debugger) في متصفحك الخاص. يوفر لك OpenReplay السياق الكامل (نشاط الشبكة، أخطاء JavaScript، إجراءات/حالة التخزين وأكثر من 40 مقياسًا) حتى تتمكن من إعادة إنتاج الأخطاء فورًا وفهم مشكلات الأداء.
|
||||
- **المساعدة (Assist):** تساعدك في دعم مستخدميك من خلال رؤية شاشتهم مباشرة والانضمام فورًا إلى مكالمة (WebRTC) معهم دون الحاجة إلى برامج مشاركة الشاشة من جهات خارجية.
|
||||
- **البحث الشامل (Omni-search):** ابحث وفرز حسب أي عملية/معيار للمستخدم تقريبًا، أو سمة الجلسة أو الحدث التقني، حتى تتمكن من الرد على أي سؤال. لا يلزم تجهيز.
|
||||
- **البحث الشامل (Omni-search):** ابحث وافرز حسب أي عملية/معيار للمستخدم تقريبًا، أو سمة الجلسة أو الحدث التقني، حتى تتمكن من الرد على أي سؤال. لا يلزم تجهيز.
|
||||
- **الأنفاق (Funnels):** للكشف عن المشكلات الأكثر تأثيرًا التي تسبب في فقدان التحويل والإيرادات.
|
||||
- **ضوابط الخصوصية الدقيقة:** اختر ماذا تريد التقاطه، ماذا تريد أن تخفي أو تجاهل حتى لا تصل بيانات المستخدم حتى إلى خوادمك.
|
||||
- **موجهة للمكونات الإضافية (Plugins oriented):** تصل إلى السبب الجذري بشكل أسرع عن طريق تتبع حالة التطبيق (Redux، VueX، MobX، NgRx، Pinia، وZustand) وتسجيل استعلامات GraphQL (Apollo، Relay) وطلبات Fetch/Axios.
|
||||
- **ضوابط الخصوصية الدقيقة:** اختر ماذا تريد التقاطه، ماذا تريد أن تخفي أو تتجاهل حتى لا تصل بيانات المستخدم حتى إلى خوادمك.
|
||||
- **موجهة للمكونات الإضافية (Plugins oriented):** يمكنك الوصول إلى السبب الجذري بشكل أسرع عن طريق تتبع حالة التطبيق (Redux، VueX، MobX، NgRx، Pinia، وZustand) وتسجيل استعلامات GraphQL (Apollo، Relay) وطلبات Fetch/Axios.
|
||||
- **التكاملات (Integrations):** مزامنة سجلات الخادم الخلفي مع إعادات التشغيل للجلسات ورؤية ما حدث من الأمام إلى الخلف. يدعم OpenReplay Sentry وDatadog وCloudWatch وStackdriver وElastic والمزيد.
|
||||
|
||||
## خيارات النشر
|
||||
|
||||
يمكن نشر OpenReplay في أي مكان. اتبع دليلنا الخطوة بالخطوة لنشره على خدمات السحابة العامة الرئيسية:
|
||||
يمكن نشر OpenReplay في أي مكان. اتبع دليلنا خطوة بخطوة لنشره على خدمات السحابة العامة الرئيسة:
|
||||
|
||||
- [AWS](https://docs.openreplay.com/deployment/deploy-aws)
|
||||
- [Google Cloud](https://docs.openreplay.com/deployment/deploy-gcp)
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ def __search_errors_mobile(project_id, value, key=None, source=None):
|
|||
query = f"""(SELECT DISTINCT ON(lg.reason)
|
||||
lg.reason AS value,
|
||||
'{events.EventType.CRASH_MOBILE.ui_type}' AS type
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_ios_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
WHERE
|
||||
s.project_id = %(project_id)s
|
||||
AND lg.project_id = %(project_id)s
|
||||
|
|
@ -241,7 +241,7 @@ def __search_errors_mobile(project_id, value, key=None, source=None):
|
|||
(SELECT DISTINCT ON(lg.name)
|
||||
lg.name AS value,
|
||||
'{events.EventType.CRASH_MOBILE.ui_type}' AS type
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_ios_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
WHERE
|
||||
s.project_id = %(project_id)s
|
||||
AND lg.project_id = %(project_id)s
|
||||
|
|
@ -251,7 +251,7 @@ def __search_errors_mobile(project_id, value, key=None, source=None):
|
|||
(SELECT DISTINCT ON(lg.reason)
|
||||
lg.reason AS value,
|
||||
'{events.EventType.CRASH_MOBILE.ui_type}' AS type
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_ios_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
WHERE
|
||||
s.project_id = %(project_id)s
|
||||
AND lg.project_id = %(project_id)s
|
||||
|
|
@ -261,7 +261,7 @@ def __search_errors_mobile(project_id, value, key=None, source=None):
|
|||
(SELECT DISTINCT ON(lg.name)
|
||||
lg.name AS value,
|
||||
'{events.EventType.CRASH_MOBILE.ui_type}' AS type
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_ios_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
WHERE
|
||||
s.project_id = %(project_id)s
|
||||
AND lg.project_id = %(project_id)s
|
||||
|
|
@ -271,7 +271,7 @@ def __search_errors_mobile(project_id, value, key=None, source=None):
|
|||
query = f"""(SELECT DISTINCT ON(lg.reason)
|
||||
lg.reason AS value,
|
||||
'{events.EventType.CRASH_MOBILE.ui_type}' AS type
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_ios_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
WHERE
|
||||
s.project_id = %(project_id)s
|
||||
AND lg.project_id = %(project_id)s
|
||||
|
|
@ -281,7 +281,7 @@ def __search_errors_mobile(project_id, value, key=None, source=None):
|
|||
(SELECT DISTINCT ON(lg.name)
|
||||
lg.name AS value,
|
||||
'{events.EventType.CRASH_MOBILE.ui_type}' AS type
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
FROM {events.EventType.CRASH_MOBILE.table} INNER JOIN public.crashes_ios AS lg USING (crash_ios_id) LEFT JOIN public.sessions AS s USING(session_id)
|
||||
WHERE
|
||||
s.project_id = %(project_id)s
|
||||
AND lg.project_id = %(project_id)s
|
||||
|
|
|
|||
|
|
@ -319,13 +319,14 @@ def create_card(project_id, user_id, data: schemas.CardSchema, dashboard=False):
|
|||
session_data = None
|
||||
if data.metric_type == schemas.MetricType.heat_map:
|
||||
if data.session_id is not None:
|
||||
session_data = json.dumps({"sessionId": data.session_id})
|
||||
session_data = {"sessionId": data.session_id}
|
||||
else:
|
||||
session_data = __get_heat_map_chart(project_id=project_id, user_id=user_id,
|
||||
data=data, include_mobs=False)
|
||||
if session_data is not None:
|
||||
session_data = json.dumps({"sessionId": session_data["sessionId"]})
|
||||
_data = {"session_data": session_data}
|
||||
session_data = {"sessionId": session_data["sessionId"]}
|
||||
|
||||
_data = {"session_data": json.dumps(session_data) if session_data is not None else None}
|
||||
for i, s in enumerate(data.series):
|
||||
for k in s.model_dump().keys():
|
||||
_data[f"{k}_{i}"] = s.__getattribute__(k)
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ class EventType:
|
|||
CUSTOM_MOBILE = Event(ui_type=schemas.EventType.custom_mobile, table="events_common.customs", column="name")
|
||||
REQUEST_MOBILE = Event(ui_type=schemas.EventType.request_mobile, table="events_common.requests", column="path")
|
||||
CRASH_MOBILE = Event(ui_type=schemas.EventType.error_mobile, table="events_common.crashes",
|
||||
column=None) # column=None because errors are searched by name or message
|
||||
column=None) # column=None because errors are searched by name or message
|
||||
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
|
|
@ -163,22 +163,25 @@ SUPPORTED_TYPES = {
|
|||
query=None),
|
||||
# MOBILE
|
||||
EventType.CLICK_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.CLICK_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.CLICK_MOBILE.ui_type)),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.CLICK_MOBILE.ui_type)),
|
||||
EventType.SWIPE_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.SWIPE_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.SWIPE_MOBILE.ui_type)),
|
||||
EventType.INPUT_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.INPUT_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.INPUT_MOBILE.ui_type)),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.INPUT_MOBILE.ui_type)),
|
||||
EventType.VIEW_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.VIEW_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.VIEW_MOBILE.ui_type)),
|
||||
EventType.CUSTOM_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.CUSTOM_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.CUSTOM_MOBILE.ui_type)),
|
||||
EventType.REQUEST_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.REQUEST_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.REQUEST_MOBILE.ui_type)),
|
||||
typename=EventType.VIEW_MOBILE.ui_type)),
|
||||
EventType.CUSTOM_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.CUSTOM_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.CUSTOM_MOBILE.ui_type)),
|
||||
EventType.REQUEST_MOBILE.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(EventType.REQUEST_MOBILE),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=EventType.REQUEST_MOBILE.ui_type)),
|
||||
EventType.CRASH_MOBILE.ui_type: SupportedFilter(get=autocomplete.__search_errors_mobile,
|
||||
query=None),
|
||||
query=None),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -359,12 +359,12 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
|
|||
distinct_on += ",path"
|
||||
if metric_format == schemas.MetricExtendedFormatType.session_count:
|
||||
main_query = f"""SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(users_sessions.session_count),0) AS total_sessions,
|
||||
COALESCE(SUM(users_sessions.session_count),0) AS count,
|
||||
COALESCE(JSONB_AGG(users_sessions)
|
||||
FILTER ( WHERE rn > %(limit_s)s
|
||||
AND rn <= %(limit_e)s ), '[]'::JSONB) AS values
|
||||
FROM (SELECT {main_col} AS name,
|
||||
count(DISTINCT session_id) AS session_count,
|
||||
count(DISTINCT session_id) AS total,
|
||||
ROW_NUMBER() OVER (ORDER BY count(full_sessions) DESC) AS rn
|
||||
FROM (SELECT *
|
||||
FROM (SELECT DISTINCT ON({distinct_on}) s.session_id, s.user_uuid,
|
||||
|
|
@ -379,7 +379,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
|
|||
ORDER BY session_count DESC) AS users_sessions;"""
|
||||
else:
|
||||
main_query = f"""SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(users_sessions.user_count),0) AS total_users,
|
||||
COALESCE(SUM(users_sessions.user_count),0) AS count,
|
||||
COALESCE(JSONB_AGG(users_sessions) FILTER ( WHERE rn <= 200 ), '[]'::JSONB) AS values
|
||||
FROM (SELECT {main_col} AS name,
|
||||
count(DISTINCT user_id) AS user_count,
|
||||
|
|
@ -420,12 +420,12 @@ def search_table_of_individual_issues(data: schemas.SessionsSearchPayloadSchema,
|
|||
full_args["issues_limit_s"] = (data.page - 1) * data.limit
|
||||
full_args["issues_limit_e"] = data.page * data.limit
|
||||
main_query = cur.mogrify(f"""SELECT COUNT(1) AS count,
|
||||
COALESCE(SUM(session_count), 0) AS total_sessions,
|
||||
COALESCE(SUM(session_count), 0) AS count,
|
||||
COALESCE(JSONB_AGG(ranked_issues)
|
||||
FILTER ( WHERE rn > %(issues_limit_s)s
|
||||
AND rn <= %(issues_limit_e)s ), '[]'::JSONB) AS values
|
||||
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY session_count DESC) AS rn
|
||||
FROM (SELECT type AS name, context_string AS value, COUNT(DISTINCT session_id) AS session_count
|
||||
FROM (SELECT type AS name, context_string AS value, COUNT(DISTINCT session_id) AS total
|
||||
FROM (SELECT session_id
|
||||
{query_part}) AS filtered_sessions
|
||||
INNER JOIN events_common.issues USING (session_id)
|
||||
|
|
@ -814,12 +814,6 @@ def search_query_parts(data: schemas.SessionsSearchPayloadSchema, error_status,
|
|||
event_where.append(
|
||||
sh.multi_conditions(f"main.{events.EventType.VIEW_MOBILE.column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
elif event_type == events.EventType.SWIPE_MOBILE.ui_type and platform == "ios":
|
||||
event_from = event_from % f"{events.EventType.SWIPE_MOBILE.table} AS main "
|
||||
if not is_any:
|
||||
event_where.append(
|
||||
sh.multi_conditions(f"main.{events.EventType.SWIPE_MOBILE.column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
elif event_type == events.EventType.CUSTOM.ui_type:
|
||||
event_from = event_from % f"{events.EventType.CUSTOM.table} AS main "
|
||||
if not is_any:
|
||||
|
|
@ -855,7 +849,7 @@ def search_query_parts(data: schemas.SessionsSearchPayloadSchema, error_status,
|
|||
event_where.append(sh.multi_conditions(f"main1.source = %({s_k})s", event.source, value_key=s_k))
|
||||
|
||||
|
||||
# ----- IOS
|
||||
# ----- Mobile
|
||||
elif event_type == events.EventType.CLICK_MOBILE.ui_type:
|
||||
event_from = event_from % f"{events.EventType.CLICK_MOBILE.table} AS main "
|
||||
if not is_any:
|
||||
|
|
@ -892,11 +886,18 @@ def search_query_parts(data: schemas.SessionsSearchPayloadSchema, error_status,
|
|||
sh.multi_conditions(f"main.{events.EventType.REQUEST_MOBILE.column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
elif event_type == events.EventType.CRASH_MOBILE.ui_type:
|
||||
event_from = event_from % f"{events.EventType.CRASH_MOBILE.table} AS main INNER JOIN public.crashes_ios AS main1 USING(crash_id)"
|
||||
event_from = event_from % f"{events.EventType.CRASH_MOBILE.table} AS main INNER JOIN public.crashes_ios AS main1 USING(crash_ios_id)"
|
||||
if not is_any and event.value not in [None, "*", ""]:
|
||||
event_where.append(
|
||||
sh.multi_conditions(f"(main1.reason {op} %({e_k})s OR main1.name {op} %({e_k})s)",
|
||||
event.value, value_key=e_k))
|
||||
elif event_type == events.EventType.SWIPE_MOBILE.ui_type and platform != "web":
|
||||
event_from = event_from % f"{events.EventType.SWIPE_MOBILE.table} AS main "
|
||||
if not is_any:
|
||||
event_where.append(
|
||||
sh.multi_conditions(f"main.{events.EventType.SWIPE_MOBILE.column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
|
||||
elif event_type == schemas.PerformanceEventType.fetch_failed:
|
||||
event_from = event_from % f"{events.EventType.REQUEST.table} AS main "
|
||||
if not is_any:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from routers.base import get_routers
|
|||
public_app, app, app_apikey = get_routers()
|
||||
|
||||
|
||||
@app.get('/{projectId}/autocomplete', tags=["events"])
|
||||
@app.get('/{projectId}/events/search', tags=["events"])
|
||||
def events_search(projectId: int, q: str,
|
||||
type: Union[schemas.FilterType, schemas.EventType,
|
||||
|
|
|
|||
|
|
@ -48,12 +48,12 @@ def transform_old_filter_type(cls, values):
|
|||
"GRAPHQL": EventType.graphql.value,
|
||||
"STATEACTION": EventType.state_action.value,
|
||||
"ERROR": EventType.error.value,
|
||||
"CLICK_MOBILE": EventType.click_mobile.value,
|
||||
"INPUT_MOBILE": EventType.input_mobile.value,
|
||||
"VIEW_MOBILE": EventType.view_mobile.value,
|
||||
"CUSTOM_MOBILE": EventType.custom_mobile.value,
|
||||
"REQUEST_MOBILE": EventType.request_mobile.value,
|
||||
"ERROR_MOBILE": EventType.error_mobile.value,
|
||||
"CLICK_IOS": EventType.click_mobile.value,
|
||||
"INPUT_IOS": EventType.input_mobile.value,
|
||||
"VIEW_IOS": EventType.view_mobile.value,
|
||||
"CUSTOM_IOS": EventType.custom_mobile.value,
|
||||
"REQUEST_IOS": EventType.request_mobile.value,
|
||||
"ERROR_IOS": EventType.error_mobile.value,
|
||||
"DOM_COMPLETE": PerformanceEventType.location_dom_complete.value,
|
||||
"LARGEST_CONTENTFUL_PAINT_TIME": PerformanceEventType.location_largest_contentful_paint_time.value,
|
||||
"TTFB": PerformanceEventType.location_ttfb.value,
|
||||
|
|
@ -471,13 +471,13 @@ class EventType(str, Enum):
|
|||
state_action = "stateAction"
|
||||
error = "error"
|
||||
tag = "tag"
|
||||
click_mobile = "tapIos"
|
||||
input_mobile = "inputIos"
|
||||
view_mobile = "viewIos"
|
||||
custom_mobile = "customIos"
|
||||
request_mobile = "requestIos"
|
||||
error_mobile = "errorIos"
|
||||
swipe_mobile = "swipeIos"
|
||||
click_mobile = "clickMobile"
|
||||
input_mobile = "inputMobile"
|
||||
view_mobile = "viewMobile"
|
||||
custom_mobile = "customMobile"
|
||||
request_mobile = "requestMobile"
|
||||
error_mobile = "errorMobile"
|
||||
swipe_mobile = "swipeMobile"
|
||||
|
||||
|
||||
class PerformanceEventType(str, Enum):
|
||||
|
|
@ -1459,7 +1459,7 @@ class LiveSessionSearchFilterSchema(BaseModel):
|
|||
operator: Literal[SearchEventOperator._is, \
|
||||
SearchEventOperator._contains] = Field(default=SearchEventOperator._contains)
|
||||
|
||||
transform = model_validator(mode='before')(transform_old_filter_type)
|
||||
_transform = model_validator(mode='before')(transform_old_filter_type)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def __validator(cls, values):
|
||||
|
|
|
|||
|
|
@ -81,13 +81,13 @@ func (s *saverImpl) handleMobileMessage(msg Message) error {
|
|||
if err = s.sessions.UpdateUserID(session.SessionID, m.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERID_Mobile", m.ID)
|
||||
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERIDMOBILE", m.ID)
|
||||
return nil
|
||||
case *MobileUserAnonymousID:
|
||||
if err = s.sessions.UpdateAnonymousID(session.SessionID, m.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERANONYMOUSID_Mobile", m.ID)
|
||||
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERANONYMOUSIDMOBILE", m.ID)
|
||||
return nil
|
||||
case *MobileMetadata:
|
||||
return s.sessions.UpdateMetadata(m.SessionID(), m.Key, m.Value)
|
||||
|
|
|
|||
|
|
@ -132,8 +132,15 @@ func (conn *Conn) InsertWebClickEvent(sess *sessions.Session, e *messages.MouseC
|
|||
}
|
||||
var host, path string
|
||||
host, path, _, _ = url.GetURLParts(e.Url)
|
||||
if e.NormalizedX <= 100 && e.NormalizedY <= 100 {
|
||||
if err := conn.bulks.Get("webClickXYEvents").Append(sess.SessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path, e.HesitationTime, e.NormalizedX, e.NormalizedY); err != nil {
|
||||
if e.NormalizedX != 101 && e.NormalizedY != 101 {
|
||||
// To support previous versions of tracker
|
||||
if e.NormalizedX <= 100 && e.NormalizedY <= 100 {
|
||||
e.NormalizedX *= 100
|
||||
e.NormalizedY *= 100
|
||||
}
|
||||
normalizedX := float32(e.NormalizedX) / 100.0
|
||||
normalizedY := float32(e.NormalizedY) / 100.0
|
||||
if err := conn.bulks.Get("webClickXYEvents").Append(sess.SessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path, e.HesitationTime, normalizedX, normalizedY); err != nil {
|
||||
sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID)
|
||||
conn.log.Error(sessCtx, "insert web click event in bulk err: %s", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ func (conn *Conn) InsertMobileEvent(session *sessions.Session, e *messages.Mobil
|
|||
if err := conn.InsertCustomEvent(session.SessionID, e.Timestamp, truncSqIdx(e.Index), e.Name, e.Payload); err != nil {
|
||||
return err
|
||||
}
|
||||
conn.InsertAutocompleteValue(session.SessionID, session.ProjectID, "CUSTOM_Mobile", e.Name)
|
||||
conn.InsertAutocompleteValue(session.SessionID, session.ProjectID, "CUSTOMMOBILE", e.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertMobileNetworkCall(sess *sessions.Session, e *messages.MobileNetworkCall) error {
|
||||
err := conn.InsertRequest(sess.SessionID, e.Timestamp, truncSqIdx(e.Index), e.URL, e.Duration, e.Status < 400)
|
||||
if err == nil {
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "REQUEST_Mobile", url.DiscardURLQuery(e.URL))
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "REQUESTMOBILE", url.DiscardURLQuery(e.URL))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ func (conn *Conn) InsertMobileClickEvent(sess *sessions.Session, clickEvent *mes
|
|||
); err != nil {
|
||||
return err
|
||||
}
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "CLICK_Mobile", clickEvent.Label)
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "CLICKMOBILE", clickEvent.Label)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ func (conn *Conn) InsertMobileSwipeEvent(sess *sessions.Session, swipeEvent *mes
|
|||
); err != nil {
|
||||
return err
|
||||
}
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "SWIPE_Mobile", swipeEvent.Label)
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "SWIPEMOBILE", swipeEvent.Label)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ func (conn *Conn) InsertMobileInputEvent(sess *sessions.Session, inputEvent *mes
|
|||
); err != nil {
|
||||
return err
|
||||
}
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "INPUT_Mobile", inputEvent.Label)
|
||||
conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "INPUTMOBILE", inputEvent.Label)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ def _get_current_auth_context(request: Request, jwt_payload: dict) -> schemas.Cu
|
|||
return request.state.currentContext
|
||||
|
||||
|
||||
def _allow_access_to_endpoint(request: Request, current_context: schemas.CurrentContext) -> bool:
|
||||
return not current_context.service_account \
|
||||
or request.url.path not in ["/logout", "/api/logout", "/refresh", "/api/refresh"]
|
||||
|
||||
|
||||
class JWTAuth(HTTPBearer):
|
||||
def __init__(self, auto_error: bool = True):
|
||||
super(JWTAuth, self).__init__(auto_error=auto_error)
|
||||
|
|
@ -68,7 +73,10 @@ class JWTAuth(HTTPBearer):
|
|||
or old_jwt_payload.get("userId") != jwt_payload.get("userId"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token or expired token.")
|
||||
|
||||
return _get_current_auth_context(request=request, jwt_payload=jwt_payload)
|
||||
ctx = _get_current_auth_context(request=request, jwt_payload=jwt_payload)
|
||||
if not _allow_access_to_endpoint(request=request, current_context=ctx):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized endpoint.")
|
||||
return ctx
|
||||
|
||||
else:
|
||||
credentials: HTTPAuthorizationCredentials = await super(JWTAuth, self).__call__(request)
|
||||
|
|
@ -95,7 +103,10 @@ class JWTAuth(HTTPBearer):
|
|||
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token or expired token.")
|
||||
|
||||
return _get_current_auth_context(request=request, jwt_payload=jwt_payload)
|
||||
ctx = _get_current_auth_context(request=request, jwt_payload=jwt_payload)
|
||||
if not _allow_access_to_endpoint(request=request, current_context=ctx):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized endpoint.")
|
||||
return ctx
|
||||
|
||||
logger.warning("Invalid authorization code.")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid authorization code.")
|
||||
|
|
|
|||
|
|
@ -339,10 +339,13 @@ def create_card(project_id, user_id, data: schemas.CardSchema, dashboard=False):
|
|||
session_data = None
|
||||
if data.metric_type == schemas.MetricType.heat_map:
|
||||
if data.session_id is not None:
|
||||
session_data = json.dumps({"sessionId": data.session_id})
|
||||
session_data = {"sessionId": data.session_id}
|
||||
else:
|
||||
session_data = __get_heat_map_chart(project_id=project_id, user_id=user_id,
|
||||
data=data, include_mobs=False)
|
||||
if session_data is not None:
|
||||
session_data = {"sessionId": session_data["sessionId"]}
|
||||
|
||||
if session_data is not None:
|
||||
# for EE only
|
||||
keys = sessions_mobs. \
|
||||
|
|
@ -356,8 +359,8 @@ def create_card(project_id, user_id, data: schemas.CardSchema, dashboard=False):
|
|||
except Exception as e:
|
||||
logger.warning(f"!!!Error while tagging: {k} to {tag} for heatMap")
|
||||
logger.error(str(e))
|
||||
session_data = json.dumps(session_data)
|
||||
_data = {"session_data": session_data}
|
||||
|
||||
_data = {"session_data": json.dumps(session_data) if session_data is not None else None}
|
||||
for i, s in enumerate(data.series):
|
||||
for k in s.model_dump().keys():
|
||||
_data[f"{k}_{i}"] = s.__getattribute__(k)
|
||||
|
|
|
|||
|
|
@ -57,16 +57,16 @@ def get_by_url(project_id, data: schemas.GetHeatMapPayloadSchema):
|
|||
# f.value, value_key=f_k))
|
||||
|
||||
if data.click_rage and not has_click_rage_filter:
|
||||
constraints.append("""(issues.session_id IS NULL
|
||||
OR (issues.datetime >= toDateTime(%(startDate)s/1000)
|
||||
AND issues.datetime <= toDateTime(%(endDate)s/1000)
|
||||
AND issues.project_id = toUInt16(%(project_id)s)
|
||||
AND issues.event_type = 'ISSUE'
|
||||
AND issues.project_id = toUInt16(%(project_id)s
|
||||
AND mis.project_id = toUInt16(%(project_id)s
|
||||
AND mis.type='click_rage'))))""")
|
||||
query_from += """ LEFT JOIN experimental.events AS issues ON (main_events.session_id=issues.session_id)
|
||||
LEFT JOIN experimental.issues AS mis ON (issues.issue_id=mis.issue_id)"""
|
||||
constraints.append("""(issues_t.session_id IS NULL
|
||||
OR (issues_t.datetime >= toDateTime(%(startDate)s/1000)
|
||||
AND issues_t.datetime <= toDateTime(%(endDate)s/1000)
|
||||
AND issues_t.project_id = toUInt16(%(project_id)s)
|
||||
AND issues_t.event_type = 'ISSUE'
|
||||
AND issues_t.project_id = toUInt16(%(project_id)s)
|
||||
AND mis.project_id = toUInt16(%(project_id)s)
|
||||
AND mis.type='click_rage'))""")
|
||||
query_from += """ LEFT JOIN experimental.events AS issues_t ON (main_events.session_id=issues_t.session_id)
|
||||
LEFT JOIN experimental.issues AS mis ON (issues_t.issue_id=mis.issue_id)"""
|
||||
with ch_client.ClickHouseClient() as cur:
|
||||
query = cur.format(f"""SELECT main_events.normalized_x AS normalized_x,
|
||||
main_events.normalized_y AS normalized_y
|
||||
|
|
|
|||
|
|
@ -442,7 +442,8 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
|
|||
if metric_format == schemas.MetricExtendedFormatType.session_count:
|
||||
main_query = f"""SELECT COUNT(DISTINCT {main_col}) OVER () AS main_count,
|
||||
{main_col} AS name,
|
||||
count(DISTINCT session_id) AS session_count
|
||||
count(DISTINCT session_id) AS session_count,
|
||||
COALESCE(SUM(count(DISTINCT session_id)) OVER (), 0) AS total_sessions
|
||||
FROM (SELECT s.session_id AS session_id,
|
||||
{extra_col}
|
||||
{query_part}) AS filtred_sessions
|
||||
|
|
@ -470,11 +471,14 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
|
|||
logging.debug("--------------------")
|
||||
sessions = cur.execute(main_query)
|
||||
count = 0
|
||||
total_sessions = 0
|
||||
if len(sessions) > 0:
|
||||
count = sessions[0]["main_count"]
|
||||
total_sessions = sessions[0]["total_sessions"]
|
||||
for s in sessions:
|
||||
s.pop("main_count")
|
||||
sessions = {"count": count, "values": helper.list_to_camel_case(sessions)}
|
||||
s.pop("total_sessions")
|
||||
sessions = {"total": count, "count": total_sessions, "values": helper.list_to_camel_case(sessions)}
|
||||
|
||||
return sessions
|
||||
|
||||
|
|
@ -520,7 +524,7 @@ def search_table_of_individual_issues(data: schemas.SessionsSearchPayloadSchema,
|
|||
total_sessions = 0
|
||||
issues_count = 0
|
||||
|
||||
return {"count": issues_count, "totalSessions": total_sessions, "values": issues}
|
||||
return {"total": issues_count, "count": total_sessions, "values": issues}
|
||||
|
||||
|
||||
def __is_valid_event(is_any: bool, event: schemas.SessionSearchEventSchema2):
|
||||
|
|
@ -563,7 +567,7 @@ def __get_event_type(event_type: Union[schemas.EventType, schemas.PerformanceEve
|
|||
schemas.PerformanceEventType.fetch_failed: "REQUEST",
|
||||
schemas.EventType.error: "CRASH",
|
||||
}
|
||||
if platform == "ios" and event_type in defs_mobile:
|
||||
if platform != "web" and event_type in defs_mobile:
|
||||
return defs_mobile.get(event_type)
|
||||
if event_type not in defs:
|
||||
raise Exception(f"unsupported EventType:{event_type}")
|
||||
|
|
@ -964,7 +968,7 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
|||
value_key=f"custom{i}"))
|
||||
full_args = {**full_args, **_multiple_values(event.source, value_key=f"custom{i}")}
|
||||
else:
|
||||
_column = events.EventType.INPUT_IOS.column
|
||||
_column = events.EventType.INPUT_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
|
|
@ -997,7 +1001,7 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
|||
event.value, value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
_column = events.EventType.VIEW_IOS.column
|
||||
_column = events.EventType.VIEW_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
|
|
@ -1089,6 +1093,114 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
|||
|
||||
events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"])
|
||||
|
||||
# ----- Mobile
|
||||
elif event_type == events.EventType.CLICK_MOBILE.ui_type:
|
||||
_column = events.EventType.CLICK_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
elif event_type == events.EventType.INPUT_MOBILE.ui_type:
|
||||
_column = events.EventType.INPUT_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
elif event_type == events.EventType.VIEW_MOBILE.ui_type:
|
||||
_column = events.EventType.VIEW_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
elif event_type == events.EventType.CUSTOM_MOBILE.ui_type:
|
||||
_column = events.EventType.CUSTOM_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
elif event_type == events.EventType.REQUEST_MOBILE.ui_type:
|
||||
event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main "
|
||||
_column = 'url_path'
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
elif event_type == events.EventType.CRASH_MOBILE.ui_type:
|
||||
_column = events.EventType.CRASH_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
elif event_type == events.EventType.SWIPE_MOBILE.ui_type and platform != "web":
|
||||
_column = events.EventType.SWIPE_MOBILE.column
|
||||
event_where.append(f"main.event_type='{__get_event_type(event_type, platform=platform)}'")
|
||||
events_conditions.append({"type": event_where[-1]})
|
||||
if not is_any:
|
||||
if is_not:
|
||||
event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value,
|
||||
value_key=e_k))
|
||||
events_conditions_not.append(
|
||||
{"type": f"sub.event_type='{__get_event_type(event_type, platform=platform)}'"})
|
||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||
else:
|
||||
event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s",
|
||||
event.value, value_key=e_k))
|
||||
events_conditions[-1]["condition"] = event_where[-1]
|
||||
|
||||
elif event_type == schemas.PerformanceEventType.fetch_failed:
|
||||
event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main "
|
||||
_column = 'url_path'
|
||||
|
|
|
|||
|
|
@ -185,8 +185,9 @@ def __filter_subquery(project_id: int, filters: Optional[schemas.SessionsSearchP
|
|||
errors_only=True, favorite_only=None,
|
||||
issue=None, user_id=None)
|
||||
params = {**params, **qp_params}
|
||||
# TODO: test if this line impacts other cards beside insights
|
||||
# sub_query = f"INNER JOIN {sub_query} USING(session_id)"
|
||||
# This line was added because insights is failing when you add filter steps,
|
||||
# for example when you add a LOCATION filter
|
||||
sub_query = f"INNER JOIN {sub_query} USING(session_id)"
|
||||
return params, sub_query
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -380,7 +380,9 @@ def get_by_email_only(email):
|
|||
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
origin,
|
||||
basic_authentication.password IS NOT NULL AS has_password
|
||||
basic_authentication.password IS NOT NULL AS has_password,
|
||||
role_id,
|
||||
internal_id
|
||||
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
||||
WHERE users.email = %(email)s
|
||||
AND users.deleted_at IS NULL
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ from starlette.datastructures import FormData
|
|||
if config("ENABLE_SSO", cast=bool, default=True):
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
API_PREFIX = "/api"
|
||||
if config("LOCAL_DEV", default=False, cast=bool):
|
||||
API_PREFIX = ""
|
||||
else:
|
||||
API_PREFIX = "/api"
|
||||
|
||||
SAML2 = {
|
||||
"strict": config("saml_strict", cast=bool, default=True),
|
||||
"debug": config("saml_debug", cast=bool, default=True),
|
||||
|
|
|
|||
|
|
@ -337,7 +337,8 @@ def get_error_trace(projectId: int, sessionId: int, errorId: str,
|
|||
}
|
||||
|
||||
|
||||
@app.get('/{projectId}/errors/{errorId}', tags=['errors'], dependencies=[OR_scope(Permissions.dev_tools)])
|
||||
@app.get('/{projectId}/errors/{errorId}', tags=['errors'],
|
||||
dependencies=[OR_scope(Permissions.dev_tools, ServicePermissions.dev_tools)])
|
||||
def errors_get_details(projectId: int, errorId: str, background_tasks: BackgroundTasks, density24: int = 24,
|
||||
density30: int = 30, context: schemas.CurrentContext = Depends(OR_context)):
|
||||
data = errors.get_details(project_id=projectId, user_id=context.user_id, error_id=errorId,
|
||||
|
|
@ -348,7 +349,8 @@ def errors_get_details(projectId: int, errorId: str, background_tasks: Backgroun
|
|||
return data
|
||||
|
||||
|
||||
@app.get('/{projectId}/errors/{errorId}/sourcemaps', tags=['errors'], dependencies=[OR_scope(Permissions.dev_tools)])
|
||||
@app.get('/{projectId}/errors/{errorId}/sourcemaps', tags=['errors'],
|
||||
dependencies=[OR_scope(Permissions.dev_tools, ServicePermissions.dev_tools)])
|
||||
def errors_get_details_sourcemaps(projectId: int, errorId: str,
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
data = errors.get_trace(project_id=projectId, error_id=errorId)
|
||||
|
|
@ -526,7 +528,7 @@ def create_note(projectId: int, sessionId: int, data: schemas.SessionNoteSchema
|
|||
|
||||
|
||||
@app.get('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"],
|
||||
dependencies=[OR_scope(Permissions.session_replay)])
|
||||
dependencies=[OR_scope(Permissions.session_replay, ServicePermissions.read_notes)])
|
||||
def get_session_notes(projectId: int, sessionId: int, context: schemas.CurrentContext = Depends(OR_context)):
|
||||
data = sessions_notes.get_session_notes(tenant_id=context.tenant_id, project_id=projectId,
|
||||
session_id=sessionId, user_id=context.user_id)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from decouple import config
|
||||
from fastapi import HTTPException, Request, Response, status
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Logout_Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from chalicelib.core import users, tenants, roles
|
||||
from chalicelib.utils import SAML2_helper
|
||||
from chalicelib.utils.SAML2_helper import prepare_request, init_saml_auth
|
||||
from routers.base import get_routers
|
||||
|
|
@ -10,12 +14,6 @@ from routers.base import get_routers
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
public_app, app, app_apikey = get_routers()
|
||||
from decouple import config
|
||||
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Logout_Request
|
||||
|
||||
from chalicelib.core import users, tenants, roles
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
|
||||
@public_app.get("/sso/saml2", tags=["saml2"])
|
||||
|
|
@ -90,15 +88,19 @@ async def process_sso_assertion(request: Request):
|
|||
logger.error("invalid tenantKey, please copy the correct value from Preferences > Account")
|
||||
return {"errors": ["invalid tenantKey, please copy the correct value from Preferences > Account"]}
|
||||
logger.debug(user_data)
|
||||
role_name = user_data.get("role", [])
|
||||
if len(role_name) == 0:
|
||||
role_names = user_data.get("role", [])
|
||||
if len(role_names) == 0:
|
||||
logger.info("No role specified, setting role to member")
|
||||
role_name = ["member"]
|
||||
role_name = role_name[0]
|
||||
role = roles.get_role_by_name(tenant_id=t['tenantId'], name=role_name)
|
||||
if role is None:
|
||||
return {"errors": [f"role {role_name} not found, please create it in openreplay first"]}
|
||||
role_names = ["member"]
|
||||
role = None
|
||||
for r in role_names:
|
||||
role = roles.get_role_by_name(tenant_id=t['tenantId'], name=r)
|
||||
if role is not None:
|
||||
break
|
||||
|
||||
if role is None:
|
||||
return {"errors": [f"role '{role_names}' not found, please create it in OpenReplay first"]}
|
||||
logger.info(f"received roles:{role_names}; using:{role['name']}")
|
||||
admin_privileges = user_data.get("adminPrivileges", [])
|
||||
admin_privileges = not (len(admin_privileges) == 0
|
||||
or admin_privileges[0] is None
|
||||
|
|
@ -122,10 +124,30 @@ async def process_sso_assertion(request: Request):
|
|||
if t['tenantId'] != existing["tenantId"]:
|
||||
logger.warning("user exists for a different tenant")
|
||||
return {"errors": ["user exists for a different tenant"]}
|
||||
if existing.get("origin") is None:
|
||||
logger.info(f"== migrating user to {SAML2_helper.get_saml2_provider()} ==")
|
||||
users.update(tenant_id=t['tenantId'], user_id=existing["userId"],
|
||||
changes={"origin": SAML2_helper.get_saml2_provider(), "internal_id": internal_id})
|
||||
# Check difference between existing user and received data
|
||||
received_data = {
|
||||
"role": "admin" if admin_privileges else "member",
|
||||
"origin": SAML2_helper.get_saml2_provider(),
|
||||
"name": " ".join(user_data.get("firstName", []) + user_data.get("lastName", [])),
|
||||
"internal_id": internal_id,
|
||||
"role_id": role["roleId"]
|
||||
}
|
||||
existing_data = {
|
||||
"role": "admin" if existing["admin"] else "member",
|
||||
"origin": existing["origin"],
|
||||
"name": existing["name"],
|
||||
"internal_id": existing["internalId"],
|
||||
"role_id": existing["roleId"]
|
||||
}
|
||||
to_update = {}
|
||||
for k in existing_data.keys():
|
||||
if (k != "role" or not existing["superAdmin"]) and existing_data[k] != received_data[k]:
|
||||
to_update[k] = received_data[k]
|
||||
|
||||
if len(to_update.keys()) > 0:
|
||||
logger.info(f"== Updating user:{existing['userId']}: {to_update} ==")
|
||||
users.update(tenant_id=t['tenantId'], user_id=existing["userId"], changes=to_update)
|
||||
|
||||
expiration = auth.get_session_expiration()
|
||||
expiration = expiration if expiration is not None and expiration > 10 * 60 \
|
||||
else int(config("sso_exp_delta_seconds", cast=int, default=24 * 60 * 60))
|
||||
|
|
@ -200,15 +222,19 @@ async def process_sso_assertion_tk(tenantKey: str, request: Request):
|
|||
logger.error("invalid tenantKey, please copy the correct value from Preferences > Account")
|
||||
return {"errors": ["invalid tenantKey, please copy the correct value from Preferences > Account"]}
|
||||
logger.debug(user_data)
|
||||
role_name = user_data.get("role", [])
|
||||
if len(role_name) == 0:
|
||||
role_names = user_data.get("role", [])
|
||||
if len(role_names) == 0:
|
||||
logger.info("No role specified, setting role to member")
|
||||
role_name = ["member"]
|
||||
role_name = role_name[0]
|
||||
role = roles.get_role_by_name(tenant_id=t['tenantId'], name=role_name)
|
||||
if role is None:
|
||||
return {"errors": [f"role {role_name} not found, please create it in openreplay first"]}
|
||||
role_names = ["member"]
|
||||
role = None
|
||||
for r in role_names:
|
||||
role = roles.get_role_by_name(tenant_id=t['tenantId'], name=r)
|
||||
if role is not None:
|
||||
break
|
||||
|
||||
if role is None:
|
||||
return {"errors": [f"role '{role_names}' not found, please create it in OpenReplay first"]}
|
||||
logger.info(f"received roles:{role_names}; using:{role['name']}")
|
||||
admin_privileges = user_data.get("adminPrivileges", [])
|
||||
admin_privileges = not (len(admin_privileges) == 0
|
||||
or admin_privileges[0] is None
|
||||
|
|
@ -232,10 +258,30 @@ async def process_sso_assertion_tk(tenantKey: str, request: Request):
|
|||
if t['tenantId'] != existing["tenantId"]:
|
||||
logger.warning("user exists for a different tenant")
|
||||
return {"errors": ["user exists for a different tenant"]}
|
||||
if existing.get("origin") is None:
|
||||
logger.info(f"== migrating user to {SAML2_helper.get_saml2_provider()} ==")
|
||||
users.update(tenant_id=t['tenantId'], user_id=existing["userId"],
|
||||
changes={"origin": SAML2_helper.get_saml2_provider(), "internal_id": internal_id})
|
||||
# Check difference between existing user and received data
|
||||
received_data = {
|
||||
"role": "admin" if admin_privileges else "member",
|
||||
"origin": SAML2_helper.get_saml2_provider(),
|
||||
"name": " ".join(user_data.get("firstName", []) + user_data.get("lastName", [])),
|
||||
"internal_id": internal_id,
|
||||
"role_id": role["roleId"]
|
||||
}
|
||||
existing_data = {
|
||||
"role": "admin" if existing["admin"] else "member",
|
||||
"origin": existing["origin"],
|
||||
"name": existing["name"],
|
||||
"internal_id": existing["internalId"],
|
||||
"role_id": existing["roleId"]
|
||||
}
|
||||
to_update = {}
|
||||
for k in existing_data.keys():
|
||||
if (k != "role" or not existing["superAdmin"]) and existing_data[k] != received_data[k]:
|
||||
to_update[k] = received_data[k]
|
||||
|
||||
if len(to_update.keys()) > 0:
|
||||
logger.info(f"== Updating user:{existing['userId']}: {to_update} ==")
|
||||
users.update(tenant_id=t['tenantId'], user_id=existing["userId"], changes=to_update)
|
||||
|
||||
expiration = auth.get_session_expiration()
|
||||
expiration = expiration if expiration is not None and expiration > 10 * 60 \
|
||||
else int(config("sso_exp_delta_seconds", cast=int, default=24 * 60 * 60))
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ServicePermissions(str, Enum):
|
|||
dev_tools = "SERVICE_DEV_TOOLS"
|
||||
assist_live = "SERVICE_ASSIST_LIVE"
|
||||
assist_call = "SERVICE_ASSIST_CALL"
|
||||
read_notes = "SERVICE_READ_NOTES"
|
||||
|
||||
|
||||
class CurrentContext(schemas.CurrentContext):
|
||||
|
|
|
|||
|
|
@ -397,12 +397,19 @@ func (c *connectorImpl) InsertWebClickEvent(session *sessions.Session, msg *mess
|
|||
if msg.Label == "" {
|
||||
return nil
|
||||
}
|
||||
var nX *uint8 = nil
|
||||
var nY *uint8 = nil
|
||||
if msg.NormalizedX <= 100 && msg.NormalizedY <= 100 {
|
||||
nXVal := uint8(msg.NormalizedX)
|
||||
var nX *float32 = nil
|
||||
var nY *float32 = nil
|
||||
if msg.NormalizedX != 101 && msg.NormalizedY != 101 {
|
||||
// To support previous versions of tracker
|
||||
if msg.NormalizedX <= 100 && msg.NormalizedY <= 100 {
|
||||
msg.NormalizedX *= 100
|
||||
msg.NormalizedY *= 100
|
||||
}
|
||||
normalizedX := float32(msg.NormalizedX) / 100.0
|
||||
normalizedY := float32(msg.NormalizedY) / 100.0
|
||||
nXVal := normalizedX
|
||||
nX = &nXVal
|
||||
nYVal := uint8(msg.NormalizedY)
|
||||
nYVal := normalizedY
|
||||
nY = &nYVal
|
||||
}
|
||||
if err := c.batches["clicks"].Append(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.19.0-ee';
|
|||
DROP TABLE IF EXISTS experimental.events_l7d_mv;
|
||||
|
||||
ALTER TABLE experimental.events
|
||||
ADD COLUMN IF NOT EXISTS normalized_x Nullable(UInt8),
|
||||
ADD COLUMN IF NOT EXISTS normalized_y Nullable(UInt8),
|
||||
ADD COLUMN IF NOT EXISTS normalized_x Nullable(Float32),
|
||||
ADD COLUMN IF NOT EXISTS normalized_y Nullable(Float32),
|
||||
DROP COLUMN IF EXISTS coordinate;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.events_l7d_mv
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ CREATE TABLE IF NOT EXISTS experimental.events
|
|||
error_tags_values Array(Nullable(String)),
|
||||
transfer_size Nullable(UInt32),
|
||||
selector Nullable(String),
|
||||
normalized_x Nullable(UInt8),
|
||||
normalized_y Nullable(UInt8),
|
||||
normalized_x Nullable(Float32),
|
||||
normalized_y Nullable(Float32),
|
||||
message_id UInt64 DEFAULT 0,
|
||||
_timestamp DateTime DEFAULT now()
|
||||
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ $fn_def$, :'next_version')
|
|||
|
||||
--
|
||||
ALTER TABLE IF EXISTS events.clicks
|
||||
ADD COLUMN IF NOT EXISTS normalized_x smallint NULL,
|
||||
ADD COLUMN IF NOT EXISTS normalized_y smallint NULL,
|
||||
ADD COLUMN IF NOT EXISTS normalized_x decimal NULL,
|
||||
ADD COLUMN IF NOT EXISTS normalized_y decimal NULL,
|
||||
DROP COLUMN IF EXISTS x,
|
||||
DROP COLUMN IF EXISTS y;
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ SET metric_type='heatMap',
|
|||
WHERE metric_type = 'clickMap';
|
||||
|
||||
UPDATE public.roles
|
||||
SET permissions='{SERVICE_SESSION_REPLAY,SERVICE_DEV_TOOLS,SERVICE_ASSIST_LIVE,SERVICE_ASSIST_CALL}'
|
||||
SET permissions='{SERVICE_SESSION_REPLAY,SERVICE_DEV_TOOLS,SERVICE_ASSIST_LIVE,SERVICE_ASSIST_CALL,SERVICE_READ_NOTES}'
|
||||
WHERE service_role;
|
||||
|
||||
UPDATE public.users
|
||||
|
|
|
|||
|
|
@ -659,16 +659,16 @@ CREATE INDEX pages_query_nn_gin_idx ON events.pages USING GIN (query gin_trgm_op
|
|||
|
||||
CREATE TABLE events.clicks
|
||||
(
|
||||
session_id bigint NOT NULL REFERENCES public.sessions (session_id) ON DELETE CASCADE,
|
||||
message_id bigint NOT NULL,
|
||||
timestamp bigint NOT NULL,
|
||||
label text DEFAULT NULL,
|
||||
url text DEFAULT '' NOT NULL,
|
||||
session_id bigint NOT NULL REFERENCES public.sessions (session_id) ON DELETE CASCADE,
|
||||
message_id bigint NOT NULL,
|
||||
timestamp bigint NOT NULL,
|
||||
label text DEFAULT NULL,
|
||||
url text DEFAULT '' NOT NULL,
|
||||
path text,
|
||||
selector text DEFAULT '' NOT NULL,
|
||||
hesitation integer DEFAULT NULL,
|
||||
normalized_x smallint DEFAULT NULL,
|
||||
normalized_y smallint DEFAULT NULL,
|
||||
selector text DEFAULT '' NOT NULL,
|
||||
hesitation integer DEFAULT NULL,
|
||||
normalized_x decimal DEFAULT NULL,
|
||||
normalized_y decimal DEFAULT NULL,
|
||||
PRIMARY KEY (session_id, message_id)
|
||||
);
|
||||
CREATE INDEX clicks_session_id_idx ON events.clicks (session_id);
|
||||
|
|
|
|||
|
|
@ -22,5 +22,5 @@ MINIO_ACCESS_KEY = ''
|
|||
MINIO_SECRET_KEY = ''
|
||||
|
||||
# APP and TRACKER VERSIONS
|
||||
VERSION = 1.19.0
|
||||
VERSION = 1.19.9
|
||||
TRACKER_VERSION = '14.0.1'
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ function ClickMapCard({
|
|||
if (mapUrl) return evt.path.includes(mapUrl)
|
||||
return evt
|
||||
}) || { timestamp: metricStore.instance.data.startTs }
|
||||
|
||||
const jumpTimestamp = (jumpToEvent.timestamp - metricStore.instance.data.startTs) + jumpToEvent.domBuildingTime + 99 // 99ms safety margin to give some time for the DOM to load
|
||||
|
||||
const ts = jumpToEvent.timestamp ?? metricStore.instance.data.startTs
|
||||
const domTime = jumpToEvent.domBuildingTime ?? 0
|
||||
const jumpTimestamp = (ts - metricStore.instance.data.startTs) + domTime + 99 // 99ms safety margin to give some time for the DOM to load
|
||||
return (
|
||||
<div id="clickmap-render">
|
||||
<ClickMapRenderer
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import ExampleFunnel from './Examples/Funnel';
|
||||
import ExamplePath from './Examples/Path';
|
||||
import ExampleTrend from './Examples/Trend';
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ interface NewDashboardModalProps {
|
|||
open: boolean;
|
||||
isAddingFromLibrary?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
||||
onClose,
|
||||
open,
|
||||
isAddingFromLibrary = false,
|
||||
isEnterprise = false
|
||||
isEnterprise = false,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const [step, setStep] = React.useState<number>(0);
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('product-analytics');
|
||||
|
|
@ -53,6 +55,7 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
|||
setSelectedCategory={setSelectedCategory}
|
||||
onCard={() => setStep(step + 1)}
|
||||
isLibrary={isAddingFromLibrary}
|
||||
isMobile={isMobile}
|
||||
isEnterprise={isEnterprise} />}
|
||||
{step === 1 && <CreateCard onBack={() => setStep(0)} />}
|
||||
</div>
|
||||
|
|
@ -63,6 +66,7 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
|||
};
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
isMobile: state.getIn(['site', 'instance', 'platform']) === 'ios',
|
||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
||||
state.getIn(['user', 'account', 'edition']) === 'msaas'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Button, Input, Segmented, Space } from 'antd';
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { ArrowRight, Info } from 'lucide-react';
|
||||
import { CARD_LIST, CARD_CATEGORIES, CardType } from './ExampleCards';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
@ -8,6 +8,7 @@ import Option from './Option';
|
|||
import CardsLibrary from 'Components/Dashboard/components/DashboardList/NewDashModal/CardsLibrary';
|
||||
import { FUNNEL } from 'App/constants/card';
|
||||
import { useHistory } from 'react-router';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
|
||||
interface SelectCardProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
|
|
@ -16,10 +17,11 @@ interface SelectCardProps {
|
|||
selected?: string;
|
||||
setSelectedCategory?: React.Dispatch<React.SetStateAction<string>>;
|
||||
isEnterprise?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const SelectCard: React.FC<SelectCardProps> = (props: SelectCardProps) => {
|
||||
const { onCard, isLibrary = false, selected, setSelectedCategory, isEnterprise } = props;
|
||||
const { onCard, isLibrary = false, selected, setSelectedCategory, isEnterprise, isMobile } = props;
|
||||
const [selectedCards, setSelectedCards] = React.useState<number[]>([]);
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
const siteId: string = location.pathname.split('/')[1];
|
||||
|
|
@ -74,20 +76,23 @@ const SelectCard: React.FC<SelectCardProps> = (props: SelectCardProps) => {
|
|||
};
|
||||
|
||||
const cardItems = useMemo(() => {
|
||||
return CARD_LIST.filter((card) => card.category === selected && (!card.isEnterprise || (card.isEnterprise && isEnterprise)))
|
||||
.map((card) => (
|
||||
<div key={card.key} className={card.width ? `col-span-${card.width}` : 'col-span-2'}>
|
||||
<card.example
|
||||
onCard={handleCardSelection}
|
||||
type={card.key}
|
||||
title={card.title}
|
||||
data={card.data}
|
||||
height={card.height}
|
||||
hideLegend={card.data?.hideLegend}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}, [selected]);
|
||||
return CARD_LIST.filter((card) =>
|
||||
card.category === selected &&
|
||||
(!card.isEnterprise || (card.isEnterprise && isEnterprise)) &&
|
||||
(!isMobile || (isMobile && ![FilterKey.USER_BROWSER].includes(card.key)))
|
||||
).map((card) => (
|
||||
<div key={card.key} className={card.width ? `col-span-${card.width}` : 'col-span-2'}>
|
||||
<card.example
|
||||
onCard={handleCardSelection}
|
||||
type={card.key}
|
||||
title={card.title}
|
||||
data={card.data}
|
||||
height={card.height}
|
||||
hideLegend={card.data?.hideLegend}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}, [selected, isEnterprise, isMobile]);
|
||||
|
||||
const onCardClick = (cardId: number) => {
|
||||
if (selectedCards.includes(cardId)) {
|
||||
|
|
@ -119,7 +124,7 @@ const SelectCard: React.FC<SelectCardProps> = (props: SelectCardProps) => {
|
|||
)}
|
||||
</div>
|
||||
{isCreatingDashboard && (
|
||||
<Button type="link" onClick={createNewDashboard} loading={dashboardCreating} className='gap-2'>
|
||||
<Button type="link" onClick={createNewDashboard} loading={dashboardCreating} className="gap-2">
|
||||
<Space>
|
||||
Create Blank
|
||||
<RightOutlined />
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ function WebPlayer(props: any) {
|
|||
const isPlayerReady = contextValue.store?.get().ready
|
||||
|
||||
React.useEffect(() => {
|
||||
contextValue.player && contextValue.player.play()
|
||||
if (isPlayerReady && insights.size > 0) {
|
||||
contextValue.player && contextValue.player.play()
|
||||
if (isPlayerReady && insights.size > 0 && jumpTimestamp) {
|
||||
setTimeout(() => {
|
||||
contextValue.player.pause()
|
||||
contextValue.player.jump(jumpTimestamp)
|
||||
|
|
|
|||
|
|
@ -83,9 +83,8 @@ function Player(props: IProps) {
|
|||
<div
|
||||
onMouseDown={handleResize}
|
||||
className={'w-full h-2 cursor-ns-resize absolute top-0 left-0 z-20'}
|
||||
>
|
||||
/>
|
||||
<ConsolePanel isLive />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!fullView && !isMultiview ? <LiveControls jump={playerContext.player.jump} /> : null}
|
||||
|
|
|
|||
|
|
@ -169,10 +169,10 @@ interface DevtoolsButtonsProps {
|
|||
bottomBlock: number;
|
||||
}
|
||||
|
||||
function DevtoolsButtons({
|
||||
const DevtoolsButtons = observer(({
|
||||
toggleBottomTools,
|
||||
bottomBlock,
|
||||
}: DevtoolsButtonsProps) {
|
||||
}: DevtoolsButtonsProps) => {
|
||||
const { aiSummaryStore } = useStore();
|
||||
const { store, player } = React.useContext(MobilePlayerContext);
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ function DevtoolsButtons({
|
|||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
const ControlPlayer = observer(Controls);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '@ant-design/icons';
|
||||
import { Button, InputNumber, Popover } from 'antd';
|
||||
import { Slider } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -24,17 +25,38 @@ function DropdownAudioPlayer({
|
|||
const [isMuted, setIsMuted] = useState(false);
|
||||
const lastPlayerTime = useRef(0);
|
||||
const audioRefs = useRef<Record<string, HTMLAudioElement | null>>({});
|
||||
|
||||
const fileLengths = useRef<Record<string, number>>({});
|
||||
const { time = 0, speed = 1, playing, sessionStart } = store?.get() ?? {};
|
||||
|
||||
const files = audioEvents.map((pa) => {
|
||||
const data = pa.payload;
|
||||
return {
|
||||
url: data.url,
|
||||
timestamp: data.timestamp,
|
||||
start: pa.timestamp - sessionStart,
|
||||
};
|
||||
});
|
||||
const files = React.useMemo(
|
||||
() =>
|
||||
audioEvents.map((pa) => {
|
||||
const data = pa.payload;
|
||||
const nativeTs = data.timestamp;
|
||||
const startTs = nativeTs
|
||||
? nativeTs > sessionStart
|
||||
? nativeTs - sessionStart
|
||||
: nativeTs
|
||||
: pa.timestamp - sessionStart;
|
||||
return {
|
||||
url: data.url,
|
||||
timestamp: data.timestamp,
|
||||
start: startTs,
|
||||
};
|
||||
}),
|
||||
[audioEvents.length, sessionStart]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
||||
if (audio) {
|
||||
audio.loop = false;
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
fileLengths.current[url] = audio.duration;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [audioRefs.current]);
|
||||
|
||||
const toggleMute = () => {
|
||||
Object.values(audioRefs.current).forEach((audio) => {
|
||||
|
|
@ -89,10 +111,15 @@ function DropdownAudioPlayer({
|
|||
if (audio) {
|
||||
const file = files.find((f) => f.url === key);
|
||||
if (file) {
|
||||
audio.currentTime = Math.max(
|
||||
(timeMs + delta * 1000 - file.start) / 1000,
|
||||
0
|
||||
);
|
||||
const targetTime = (timeMs + delta * 1000 - file.start) / 1000;
|
||||
const fileLength = fileLengths.current[key];
|
||||
if (targetTime < 0 || (fileLength && targetTime > fileLength)) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
return;
|
||||
} else {
|
||||
audio.currentTime = targetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -108,27 +135,39 @@ function DropdownAudioPlayer({
|
|||
|
||||
useEffect(() => {
|
||||
const deltaMs = delta * 1000;
|
||||
if (Math.abs(lastPlayerTime.current - time - deltaMs) >= 250) {
|
||||
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs);
|
||||
if (deltaTime >= 250) {
|
||||
handleSeek(time);
|
||||
}
|
||||
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
||||
if (audio) {
|
||||
const file = files.find((f) => f.url === url);
|
||||
if (file && time >= file.start) {
|
||||
if (audio.paused && playing) {
|
||||
audio.play();
|
||||
const fileLength = fileLengths.current[url];
|
||||
if (file) {
|
||||
if (fileLength && fileLength * 1000 + file.start < time) {
|
||||
return;
|
||||
}
|
||||
if (time >= file.start) {
|
||||
if (audio.paused && playing) {
|
||||
audio.play();
|
||||
}
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (audio.muted !== isMuted) {
|
||||
audio.muted = isMuted;
|
||||
}
|
||||
}
|
||||
});
|
||||
lastPlayerTime.current = time + deltaMs;
|
||||
}, [time, delta]);
|
||||
|
||||
useEffect(() => {
|
||||
Object.values(audioRefs.current).forEach((audio) => {
|
||||
if (audio) {
|
||||
audio.muted = isMuted;
|
||||
}
|
||||
});
|
||||
}, [isMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
changePlaybackSpeed(speed);
|
||||
}, [speed]);
|
||||
|
|
@ -137,22 +176,30 @@ function DropdownAudioPlayer({
|
|||
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
||||
if (audio) {
|
||||
const file = files.find((f) => f.url === url);
|
||||
if (file && playing && time >= file.start) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
const fileLength = fileLengths.current[url];
|
||||
if (file) {
|
||||
if (fileLength && fileLength * 1000 + file.start < time) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
if (playing && time >= file.start) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setVolume(isMuted ? 0 : volume);
|
||||
}, [playing]);
|
||||
|
||||
const buttonIcon =
|
||||
'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit';
|
||||
return (
|
||||
<div className={'relative'}>
|
||||
<div className={'flex items-center'} style={{ height: 24 }}>
|
||||
<Popover
|
||||
trigger={'click'}
|
||||
className={'h-full'}
|
||||
content={
|
||||
<div
|
||||
className={'flex flex-col gap-2 rounded'}
|
||||
|
|
@ -169,20 +216,14 @@ function DropdownAudioPlayer({
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'px-2 h-full cursor-pointer border rounded-l border-gray-light hover:border-main hover:text-main hover:z-10'
|
||||
}
|
||||
>
|
||||
<div className={cn(buttonIcon, 'rounded-l')}>
|
||||
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</div>
|
||||
</Popover>
|
||||
<div
|
||||
onClick={toggleVisible}
|
||||
style={{ marginLeft: -1 }}
|
||||
className={
|
||||
'px-2 h-full border rounded-r border-gray-light cursor-pointer hover:border-main hover:text-main hover:z-10'
|
||||
}
|
||||
className={cn(buttonIcon, 'rounded-r')}
|
||||
>
|
||||
<CaretDownOutlined />
|
||||
</div>
|
||||
|
|
@ -236,6 +277,7 @@ function DropdownAudioPlayer({
|
|||
<div style={{ display: 'none' }}>
|
||||
{files.map((file) => (
|
||||
<audio
|
||||
loop={false}
|
||||
key={file.url}
|
||||
ref={(el) => (audioRefs.current[file.url] = el)}
|
||||
controls
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ function UserCard({ className, request, session, width, height, similarSessions,
|
|||
userDisplayName,
|
||||
userDeviceType,
|
||||
revId,
|
||||
screenWidth,
|
||||
screenHeight
|
||||
} = session;
|
||||
|
||||
const hasUserDetails = !!userId || !!userAnonymousId;
|
||||
|
|
@ -137,7 +139,7 @@ function UserCard({ className, request, session, width, height, similarSessions,
|
|||
<SessionInfoItem
|
||||
icon={deviceTypeIcon(userDeviceType)}
|
||||
label={userDeviceType}
|
||||
value={getDimension(width, height)}
|
||||
value={getDimension(width || screenWidth, height || screenHeight)}
|
||||
isLast={!revId}
|
||||
/>
|
||||
{revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ function ConsoleRow(props: Props) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn(stl.line, 'flex py-2 px-4 overflow-hidden group relative select-none', {
|
||||
className={cn(stl.line, 'flex py-2 px-4 overflow-hidden group relative', {
|
||||
info: !log.isYellow && !log.isRed,
|
||||
warn: log.isYellow,
|
||||
error: log.isRed,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
height: 100%;
|
||||
/* border: solid thin $gray-light; */
|
||||
/* border-radius: 3px; */
|
||||
overflow: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.checkers {
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ function ConsolePanel({
|
|||
exceptionsList = [],
|
||||
logListNow = [],
|
||||
exceptionsListNow = [],
|
||||
} = tabStates[currentTab];
|
||||
} = tabStates[currentTab] ?? {};
|
||||
|
||||
const list = isLive
|
||||
? (useMemo(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function ConsoleRow(props: Props) {
|
|||
<div
|
||||
style={style}
|
||||
className={cn(
|
||||
'border-b flex items-start py-1 px-4 pe-8 overflow-hidden group relative select-none',
|
||||
'border-b flex items-start py-1 px-4 pe-8 overflow-hidden group relative',
|
||||
{
|
||||
info: !log.isYellow && !log.isRed,
|
||||
warn: log.isYellow,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const ALL = 'ALL';
|
|||
const TAB_KEYS = [ALL, ...typeList] as const;
|
||||
const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab }));
|
||||
|
||||
type EventsList = Array<Timed & { name: string; source: string; key: string }>;
|
||||
type EventsList = Array<Timed & { name: string; source: string; key: string; payload?: string[] }>;
|
||||
|
||||
const WebStackEventPanelComp = observer(
|
||||
({
|
||||
|
|
@ -96,7 +96,7 @@ export const MobileStackEventPanel = connect((state: Record<string, any>) => ({
|
|||
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
|
||||
}))(MobileStackEventPanelComp);
|
||||
|
||||
function EventsPanel({
|
||||
const EventsPanel = observer(({
|
||||
list,
|
||||
listNow,
|
||||
jump,
|
||||
|
|
@ -110,7 +110,7 @@ function EventsPanel({
|
|||
zoomEnabled: boolean;
|
||||
zoomStartTs: number;
|
||||
zoomEndTs: number;
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
sessionStore: { devTools },
|
||||
} = useStore();
|
||||
|
|
@ -127,13 +127,19 @@ function EventsPanel({
|
|||
zoomEnabled ? zoomStartTs <= time && time <= zoomEndTs : true
|
||||
);
|
||||
|
||||
let filteredList = useRegExListFilterMemo(inZoomRangeList, (it) => it.name, filter);
|
||||
let filteredList = useRegExListFilterMemo(inZoomRangeList, (it) => {
|
||||
const searchBy = [it.name]
|
||||
if (it.payload) {
|
||||
const payload = Array.isArray(it.payload) ? it.payload.join(',') : JSON.stringify(it.payload);
|
||||
searchBy.push(payload);
|
||||
}
|
||||
return searchBy
|
||||
}, filter);
|
||||
filteredList = useTabListFilterMemo(filteredList, (it) => it.source, ALL, activeTab);
|
||||
|
||||
const onTabClick = (activeTab: (typeof TAB_KEYS)[number]) =>
|
||||
devTools.update(INDEX_KEY, { activeTab });
|
||||
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
|
||||
devTools.update(INDEX_KEY, { filter: value });
|
||||
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => devTools.update(INDEX_KEY, { filter: value });
|
||||
const tabs = useMemo(
|
||||
() => TABS.filter(({ key }) => key === ALL || inZoomRangeList.some(({ source }) => key === source)),
|
||||
[inZoomRangeList.length]
|
||||
|
|
@ -254,4 +260,4 @@ function EventsPanel({
|
|||
</BottomBlock.Content>
|
||||
</BottomBlock>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { formatBytes } from 'App/utils';
|
||||
import CopyText from 'Shared/CopyText';
|
||||
import {Tag} from 'antd';
|
||||
import { Tag } from 'antd';
|
||||
import cn from 'classnames';
|
||||
|
||||
interface Props {
|
||||
resource: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
function FetchBasicDetails({ resource, timestamp }: Props) {
|
||||
const _duration = parseInt(resource.duration);
|
||||
const text = useMemo(() => {
|
||||
|
|
@ -22,14 +23,16 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
<div>
|
||||
<div className="flex items-start py-1">
|
||||
<div className="font-medium w-36">Name</div>
|
||||
<Tag className='text-base max-w-96 rounded-lg text-clip bg-indigo-50 whitespace-nowrap overflow-hidden text-clip cursor-pointer word-break' bordered={false}>
|
||||
<Tag
|
||||
className="text-base max-w-96 rounded-lg text-clip bg-indigo-50 whitespace-nowrap overflow-hidden text-clip cursor-pointer word-break"
|
||||
bordered={false}>
|
||||
<CopyText content={resource.url}>{resource.url}</CopyText>
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Type</div>
|
||||
<Tag className='text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip' bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
{resource.type}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -37,7 +40,8 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
{resource.method && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Request Method</div>
|
||||
<Tag className='text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip' bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{resource.method}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -47,15 +51,12 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
<div className="flex items-center py-1">
|
||||
<div className="text-base font-medium w-36">Status Code</div>
|
||||
<Tag
|
||||
bordered={false}
|
||||
bordered={false}
|
||||
className={cn(
|
||||
'text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip flex items-center',
|
||||
{ 'error color-red': !resource.success }
|
||||
)}
|
||||
>
|
||||
{resource.status === '200' && (
|
||||
<Tag bordered={false} className="text-base bg-emerald-100 rounded-full mr-2"></Tag>
|
||||
)}
|
||||
{resource.status}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -63,7 +64,8 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Type</div>
|
||||
<Tag className="text-base capitalize rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
<Tag className="text-base capitalize rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{resource.type}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -71,18 +73,19 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
{!!resource.decodedBodySize && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Size</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{formatBytes(resource.decodedBodySize)}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{!!_duration && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Duration</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{_duration} ms
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -90,11 +93,12 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
|
||||
{timestamp && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Time</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
{timestamp}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="font-medium w-36">Time</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{timestamp}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { connect } from 'react-redux';
|
|||
import { Icon, Loader } from 'UI';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
import { FilterKey } from '../../../../types/filter/filterType';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import stl from './FilterModal.module.css';
|
||||
|
||||
const IconMap = {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ interface Props {
|
|||
function Activity(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6 2a.5.5 0 0 1 .47.33L10 12.036l1.53-4.208A.5.5 0 0 1 12 7.5h3.5a.5.5 0 0 1 0 1h-3.15l-1.88 5.17a.5.5 0 0 1-.94 0L6 3.964 4.47 8.171A.5.5 0 0 1 4 8.5H.5a.5.5 0 0 1 0-1h3.15l1.88-5.17A.5.5 0 0 1 6 2Z"/></svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function Console_info(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/><path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Filters_chevrons_up_down(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Filters_chevrons_up_down;
|
||||
19
frontend/app/components/ui/Icons/filters_screen.tsx
Normal file
19
frontend/app/components/ui/Icons/filters_screen.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Filters_screen(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Filters_screen;
|
||||
|
|
@ -275,6 +275,7 @@ export { default as Filetype_pdf } from './filetype_pdf';
|
|||
export { default as Filter } from './filter';
|
||||
export { default as Filters_arrow_return_right } from './filters_arrow_return_right';
|
||||
export { default as Filters_browser } from './filters_browser';
|
||||
export { default as Filters_chevrons_up_down } from './filters_chevrons_up_down';
|
||||
export { default as Filters_click } from './filters_click';
|
||||
export { default as Filters_clickrage } from './filters_clickrage';
|
||||
export { default as Filters_code } from './filters_code';
|
||||
|
|
@ -303,6 +304,7 @@ export { default as Filters_platform } from './filters_platform';
|
|||
export { default as Filters_referrer } from './filters_referrer';
|
||||
export { default as Filters_resize } from './filters_resize';
|
||||
export { default as Filters_rev_id } from './filters_rev_id';
|
||||
export { default as Filters_screen } from './filters_screen';
|
||||
export { default as Filters_state_action } from './filters_state_action';
|
||||
export { default as Filters_tag_element } from './filters_tag_element';
|
||||
export { default as Filters_ttfb } from './filters_ttfb';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
function Pdf_download(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-down"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>
|
||||
<svg viewBox="0 0 19 19" width={ `${ width }px` } height={ `${ height }px` } ><path d="M10.094 5.249a.594.594 0 0 0-1.188 0v4.504l-1.36-1.362a.595.595 0 0 0-.841.84l2.375 2.376a.596.596 0 0 0 .84 0l2.375-2.375a.595.595 0 0 0-.84-.841l-1.361 1.362V5.249Z"/><path d="M16.625 16.625V5.344L11.281 0H4.75a2.375 2.375 0 0 0-2.375 2.375v14.25A2.375 2.375 0 0 0 4.75 19h9.5a2.375 2.375 0 0 0 2.375-2.375ZM11.281 3.562a1.781 1.781 0 0 0 1.781 1.782h2.376v11.281a1.188 1.188 0 0 1-1.188 1.188h-9.5a1.187 1.187 0 0 1-1.188-1.188V2.375A1.188 1.188 0 0 1 4.75 1.187h6.531v2.375Z"/><path clipRule="evenodd" d="M15.58 13.49H3.42v4.37h1.789v-3.512H6.61c.282 0 .524.051.726.154.205.103.361.245.47.425.11.178.165.383.165.613 0 .226-.055.424-.164.593-.11.169-.266.3-.47.396-.203.093-.445.14-.727.14h-.554v1.191h2.37v-3.512h1.13c.238 0 .455.041.653.123a1.537 1.537 0 0 1 .854.88c.08.205.12.431.12.68v.148c0 .247-.04.474-.12.68a1.512 1.512 0 0 1-.849.88c-.193.08-.404.12-.635.121h2.046v-3.512h2.35v.654h-1.503v.808h1.365v.651h-1.365v1.399h3.108v-4.37Zm-9.524 1.512v1.013h.554c.12 0 .216-.02.29-.06a.367.367 0 0 0 .161-.167.547.547 0 0 0 .054-.244.668.668 0 0 0-.054-.267.431.431 0 0 0-.161-.198.496.496 0 0 0-.29-.077h-.554Zm3.512 2.207h-.295v-2.207h.283c.123 0 .233.021.328.065a.604.604 0 0 1 .24.195.88.88 0 0 1 .146.321c.033.127.05.275.05.444v.152c0 .225-.03.415-.089.569a.707.707 0 0 1-.256.345.696.696 0 0 1-.407.116Z"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
function Pencil(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-pen-line"><path d="m18 5-2.414-2.414A2 2 0 0 0 14.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2"/><path d="M21.378 12.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/><path d="M8 18h1"/></svg>
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
function Trash(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ interface Props {
|
|||
function Users(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users-round"><path d="M18 21a8 8 0 0 0-16 0"/><circle cx="10" cy="8" r="5"/><path d="M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3"/></svg>
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -76,7 +76,7 @@ function reducer(state = initialState, action = {}) {
|
|||
switch (action.type) {
|
||||
case REFRESH_FILTER_OPTIONS:
|
||||
return state
|
||||
.set('filterList', generateFilterOptions(filtersMap))
|
||||
.set('filterList', generateFilterOptions(filtersMap, action.isMobile))
|
||||
.set('filterListLive', generateFilterOptions(liveFiltersMap))
|
||||
.set(
|
||||
'filterListConditional',
|
||||
|
|
@ -466,10 +466,12 @@ export const editSavedSearch = (instance) => {
|
|||
};
|
||||
};
|
||||
|
||||
export const refreshFilterOptions = () => {
|
||||
return {
|
||||
export const refreshFilterOptions = () => (dispatch, getState) => {
|
||||
const currentProject = getState().getIn(['site', 'instance']);
|
||||
return dispatch({
|
||||
type: REFRESH_FILTER_OPTIONS,
|
||||
};
|
||||
isMobile: currentProject?.platform === 'ios'
|
||||
});
|
||||
};
|
||||
|
||||
export const setScrollPosition = (scrollPosition) => {
|
||||
|
|
|
|||
|
|
@ -302,8 +302,8 @@ export default class Widget {
|
|||
} else if (this.metricType === FUNNEL) {
|
||||
_data.funnel = new Funnel().fromJSON(_data);
|
||||
} else if (this.metricType === TABLE) {
|
||||
const totalSessions = data[0]['totalSessions'];
|
||||
_data[0]['values'] = data[0]['values'].map((s: any) => new SessionsByRow().fromJson(s, totalSessions, this.metricOf));
|
||||
const count = data[0]['count'];
|
||||
_data[0]['values'] = data[0]['values'].map((s: any) => new SessionsByRow().fromJson(s, count, this.metricOf));
|
||||
} else {
|
||||
if (data.hasOwnProperty('chart')) {
|
||||
_data['value'] = data.value;
|
||||
|
|
|
|||
|
|
@ -78,10 +78,10 @@ export interface State extends ScreenState, ListsState {
|
|||
}
|
||||
|
||||
const userEvents = [
|
||||
MType.IosSwipeEvent,
|
||||
MType.IosClickEvent,
|
||||
MType.IosInputEvent,
|
||||
MType.IosScreenChanges,
|
||||
MType.MobileSwipeEvent,
|
||||
MType.MobileClickEvent,
|
||||
MType.MobileInputEvent,
|
||||
MType.MobileScreenChanges,
|
||||
];
|
||||
|
||||
export default class IOSMessageManager implements IMessageManager {
|
||||
|
|
@ -233,7 +233,7 @@ export default class IOSMessageManager implements IMessageManager {
|
|||
}
|
||||
|
||||
switch (msg.tp) {
|
||||
case MType.IosPerformanceEvent:
|
||||
case MType.MobilePerformanceEvent:
|
||||
const performanceStats = ['background', 'memoryUsage', 'mainThreadCPU'];
|
||||
if (performanceStats.includes(msg.name)) {
|
||||
this.performanceManager.append(msg);
|
||||
|
|
@ -253,21 +253,21 @@ export default class IOSMessageManager implements IMessageManager {
|
|||
// case MType.IosInputEvent:
|
||||
// console.log('input', msg)
|
||||
// break;
|
||||
case MType.IosNetworkCall:
|
||||
case MType.MobileNetworkCall:
|
||||
this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart));
|
||||
break;
|
||||
case MType.WsChannel:
|
||||
this.lists.lists.websocket.insert(msg);
|
||||
break;
|
||||
case MType.IosEvent:
|
||||
case MType.MobileEvent:
|
||||
// @ts-ignore
|
||||
this.lists.lists.event.insert({ ...msg, source: 'openreplay' });
|
||||
break;
|
||||
case MType.IosSwipeEvent:
|
||||
case MType.IosClickEvent:
|
||||
case MType.MobileSwipeEvent:
|
||||
case MType.MobileClickEvent:
|
||||
this.touchManager.append(msg);
|
||||
break;
|
||||
case MType.IosLog:
|
||||
case MType.MobileLog:
|
||||
const log = { ...msg, level: msg.severity };
|
||||
// @ts-ignore
|
||||
this.lists.lists.log.append(Log(log));
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default class TouchManager extends ListWalker<IosClickEvent | IosSwipeEve
|
|||
public move(t: number) {
|
||||
const lastTouch = this.moveGetLast(t)
|
||||
if (!!lastTouch) {
|
||||
if (lastTouch.tp === MType.IosSwipeEvent) {
|
||||
if (lastTouch.tp === MType.MobileSwipeEvent) {
|
||||
return
|
||||
// not using swipe rn
|
||||
// this.touchTrail?.createSwipeTrail({
|
||||
|
|
|
|||
|
|
@ -233,10 +233,10 @@ export default class Screen {
|
|||
break;
|
||||
case ScaleMode.AdjustParentHeight:
|
||||
// we want to scale the document with true height so the clickmap will be scrollable
|
||||
const usedHeight =
|
||||
this.document?.body.scrollHeight && this.document?.body.scrollHeight > height
|
||||
? this.document.body.scrollHeight + 'px'
|
||||
: height + 'px';
|
||||
const usedHeight = height + 'px';
|
||||
// this.document?.body.scrollHeight && this.document?.body.scrollHeight > height
|
||||
// ? this.document.body.scrollHeight + 'px'
|
||||
// : height + 'px';
|
||||
this.scaleRatio = offsetWidth / width;
|
||||
translate = 'translate(-50%, 0)';
|
||||
posStyles = { top: 0, height: usedHeight };
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
position: absolute;
|
||||
width: 36px;
|
||||
height: 60px;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg enable-background='new 0 0 28 28' version='1.1' viewBox='0 0 28 28' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='8.2 20.9 8.2 4.9 19.8 16.5 13 16.5 12.6 16.6' fill='%23fff'/%3E%3Cpolygon points='17.3 21.6 13.7 23.1 9 12 12.7 10.5' fill='%23fff'/%3E%3Crect transform='matrix(.9221 -.3871 .3871 .9221 -5.7605 6.5909)' x='12.5' y='13.6' width='2' height='8'/%3E%3Cpolygon points='9.2 7.3 9.2 18.5 12.2 15.6 12.6 15.5 17.4 15.5'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg enable-background='new 0 0 20 20' version='1.1' viewBox='8.5 5.2 28 28' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='8.2 20.9 8.2 4.9 19.8 16.5 13 16.5 12.6 16.6' fill='%23fff'/%3E%3Cpolygon points='17.3 21.6 13.7 23.1 9 12 12.7 10.5' fill='%23fff'/%3E%3Crect transform='matrix(.9221 -.3871 .3871 .9221 -5.7605 6.5909)' x='12.5' y='13.6' width='2' height='8'/%3E%3Cpolygon points='9.2 7.3 9.2 18.5 12.2 15.6 12.6 15.5 17.4 15.5'/%3E%3C/svg%3E%0A");
|
||||
background-repeat: no-repeat;
|
||||
transition: top .15s ease-out, left .15s ease-out;
|
||||
|
||||
|
|
|
|||
|
|
@ -146,39 +146,37 @@ export default class TargetMarker {
|
|||
if (clicks && this.screen.document) {
|
||||
this.clickMapOverlay?.remove();
|
||||
const overlay = document.createElement('canvas');
|
||||
const iframeSize = this.screen.iframeStylesRef;
|
||||
const scrollHeight = this.screen.document?.documentElement.scrollHeight || 0;
|
||||
const scrollWidth = this.screen.document?.documentElement.scrollWidth || 0;
|
||||
const scaleRatio = this.screen.getScale();
|
||||
|
||||
Object.assign(
|
||||
overlay.style,
|
||||
clickmapStyles.overlayStyle({
|
||||
height: iframeSize.height,
|
||||
width: iframeSize.width,
|
||||
scale: scaleRatio,
|
||||
height: scrollHeight + 'px',
|
||||
width: scrollWidth + 'px',
|
||||
})
|
||||
);
|
||||
|
||||
this.clickMapOverlay = overlay;
|
||||
this.screen.getParentElement()?.appendChild(overlay);
|
||||
this.screen.document.body.appendChild(overlay);
|
||||
|
||||
const pointMap: Record<string, { times: number; data: number[], original: any }> = {};
|
||||
const ovWidth = parseInt(iframeSize.width);
|
||||
const ovHeight = parseInt(iframeSize.height);
|
||||
overlay.width = ovWidth;
|
||||
overlay.height = ovHeight;
|
||||
overlay.width = scrollWidth;
|
||||
overlay.height = scrollHeight;
|
||||
let maxIntensity = 0;
|
||||
|
||||
clicks.forEach((point) => {
|
||||
const key = `${point.normalizedY}-${point.normalizedX}`;
|
||||
const y = roundToSecond(point.normalizedY);
|
||||
const x = roundToSecond(point.normalizedX);
|
||||
const key = `${y}-${x}`;
|
||||
if (pointMap[key]) {
|
||||
const times = pointMap[key].times + 1;
|
||||
maxIntensity = Math.max(maxIntensity, times);
|
||||
pointMap[key].times = times;
|
||||
} else {
|
||||
const clickData = [
|
||||
(point.normalizedX / 100) * scrollWidth,
|
||||
(point.normalizedY / 100) * scrollHeight,
|
||||
(x / 100) * scrollWidth,
|
||||
(y / 100) * scrollHeight,
|
||||
];
|
||||
pointMap[key] = { times: 1, data: clickData, original: point };
|
||||
}
|
||||
|
|
@ -204,3 +202,7 @@ export default class TargetMarker {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function roundToSecond(num: number) {
|
||||
return Math.round(num * 100) / 100;
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
export const clickmapStyles = {
|
||||
overlayStyle: ({ height, width, scale }: { height: string, width: string, scale: number }) => ({
|
||||
transform: `scale(${scale}) translate(-50%, 0)`,
|
||||
overlayStyle: ({ height, width }: { height: string, width: string }) => ({
|
||||
position: 'absolute',
|
||||
top: '0px',
|
||||
left: '50%',
|
||||
left: 0,
|
||||
width,
|
||||
height,
|
||||
background: 'rgba(0,0,0, 0.15)',
|
||||
zIndex: 9 * 10e3,
|
||||
transformOrigin: 'left top',
|
||||
}),
|
||||
totalClicks: {
|
||||
fontSize: '16px',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import logger from 'App/logger';
|
||||
import { resolveURL } from "../../messages/rewriter/urlResolve";
|
||||
|
||||
import type Screen from '../../Screen/Screen';
|
||||
import type { Message, SetNodeScroll } from '../../messages';
|
||||
|
|
@ -32,6 +31,8 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
private readonly vTexts: Map<number, VText> = new Map() // map vs object here?
|
||||
private readonly vElements: Map<number, VElement> = new Map()
|
||||
private readonly olVRoots: Map<number, OnloadVRoot> = new Map()
|
||||
/** required to keep track of iframes, frameId : vnodeId */
|
||||
private readonly iframeRoots: Record<number, number> = {}
|
||||
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
|
||||
* as well as <style> tag owned StyleSheets
|
||||
*/
|
||||
|
|
@ -219,6 +220,10 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) {
|
||||
vElem.prioritized = true
|
||||
}
|
||||
if (this.vElements.has(msg.id)) {
|
||||
logger.error("CreateElementNode: Node already exists", msg)
|
||||
return
|
||||
}
|
||||
this.vElements.set(msg.id, vElem)
|
||||
this.insertNode(msg)
|
||||
this.removeBodyScroll(msg.id, vElem)
|
||||
|
|
@ -316,6 +321,10 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
case MType.CreateIFrameDocument: {
|
||||
const vElem = this.vElements.get(msg.frameID)
|
||||
if (!vElem) { logger.error("CreateIFrameDocument: Node not found", msg); return }
|
||||
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
|
||||
this.olVRoots.delete(this.iframeRoots[msg.frameID])
|
||||
}
|
||||
this.iframeRoots[msg.frameID] = msg.id
|
||||
const vRoot = OnloadVRoot.fromVElement(vElem)
|
||||
vRoot.catch(e => logger.warn(e, msg))
|
||||
this.olVRoots.set(msg.id, vRoot)
|
||||
|
|
|
|||
1
frontend/app/svg/icons/filters/chevrons-up-down.svg
Normal file
1
frontend/app/svg/icons/filters/chevrons-up-down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
1
frontend/app/svg/icons/filters/screen.svg
Normal file
1
frontend/app/svg/icons/filters/screen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-view"><path d="M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2"/><path d="M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>
|
||||
|
After Width: | Height: | Size: 430 B |
|
|
@ -196,6 +196,14 @@ export enum FilterType {
|
|||
}
|
||||
|
||||
export enum FilterKey {
|
||||
CLICK_MOBILE = 'clickMobile',
|
||||
INPUT_MOBILE = 'inputMobile',
|
||||
VIEW_MOBILE = 'viewMobile',
|
||||
CUSTOM_MOBILE = 'customMobile',
|
||||
REQUEST_MOBILE = 'requestMobile',
|
||||
ERROR_MOBILE = 'errorMobile',
|
||||
SWIPE_MOBILE = 'swipeMobile',
|
||||
|
||||
ERROR = 'error',
|
||||
MISSING_RESOURCE = 'missingResource',
|
||||
SLOW_SESSION = 'slowSession',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { stringConditional, tagElementOperators, targetConditional } from "App/constants/filterOptions";
|
||||
import { stringConditional, tagElementOperators, targetConditional } from 'App/constants/filterOptions';
|
||||
import { KEYS } from 'Types/filter/customFilter';
|
||||
import Record from 'Types/Record';
|
||||
import { FilterType, FilterKey, FilterCategory } from './filterType';
|
||||
|
|
@ -13,10 +13,78 @@ const filterOrder = {
|
|||
[FilterCategory.TECHNICAL]: 1,
|
||||
[FilterCategory.PERFORMANCE]: 2,
|
||||
[FilterCategory.USER]: 3,
|
||||
[FilterCategory.GEAR]: 4,
|
||||
}
|
||||
[FilterCategory.GEAR]: 4
|
||||
};
|
||||
|
||||
export const mobileFilters = [
|
||||
{
|
||||
key: FilterKey.CLICK_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Tap',
|
||||
operator: 'on',
|
||||
operatorOptions: filterOptions.targetOperators,
|
||||
icon: 'filters/click',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.INPUT_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Text Input',
|
||||
placeholder: 'Enter input label name',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/input',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.VIEW_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Screen',
|
||||
placeholder: 'Enter screen name',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/screen',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.CUSTOM_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.TECHNICAL,
|
||||
label: 'Custom Events',
|
||||
placeholder: 'Enter event key',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/custom',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.ERROR_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.TECHNICAL,
|
||||
label: 'Error Message',
|
||||
placeholder: 'E.g. Uncaught SyntaxError',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/error',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.SWIPE_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Swipe',
|
||||
operator: 'on',
|
||||
operatorOptions: filterOptions.targetOperators,
|
||||
icon: 'filters/chevrons-up-down',
|
||||
isEvent: true
|
||||
}
|
||||
];
|
||||
|
||||
export const filters = [
|
||||
...mobileFilters,
|
||||
{
|
||||
key: FilterKey.CLICK,
|
||||
type: FilterType.MULTIPLE,
|
||||
|
|
@ -96,7 +164,7 @@ export const filters = [
|
|||
operator: 'is',
|
||||
placeholder: 'Select method type',
|
||||
operatorOptions: filterOptions.stringOperatorsLimited,
|
||||
icon: 'filters/fetch',
|
||||
icon: 'filters/fetch',
|
||||
options: filterOptions.methodOptions
|
||||
},
|
||||
{
|
||||
|
|
@ -232,7 +300,7 @@ export const filters = [
|
|||
isEvent: true,
|
||||
icon: 'filters/tag-element',
|
||||
operatorOptions: filterOptions.tagElementOperators,
|
||||
options: [],
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: FilterKey.UTM_SOURCE,
|
||||
|
|
@ -241,7 +309,7 @@ export const filters = [
|
|||
label: 'UTM Source',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/country',
|
||||
icon: 'filters/country'
|
||||
},
|
||||
{
|
||||
key: FilterKey.UTM_MEDIUM,
|
||||
|
|
@ -250,7 +318,7 @@ export const filters = [
|
|||
label: 'UTM Medium',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/country',
|
||||
icon: 'filters/country'
|
||||
},
|
||||
{
|
||||
key: FilterKey.UTM_CAMPAIGN,
|
||||
|
|
@ -259,7 +327,7 @@ export const filters = [
|
|||
label: 'UTM Campaign',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/country',
|
||||
icon: 'filters/country'
|
||||
},
|
||||
{
|
||||
key: FilterKey.USER_COUNTRY,
|
||||
|
|
@ -471,12 +539,12 @@ export const filters = [
|
|||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'collection'
|
||||
},
|
||||
}
|
||||
].sort((a, b) => {
|
||||
const aOrder = filterOrder[a.category] ?? 9
|
||||
const bOrder = filterOrder[b.category] ?? 9
|
||||
return aOrder - bOrder
|
||||
})
|
||||
const aOrder = filterOrder[a.category] ?? 9;
|
||||
const bOrder = filterOrder[b.category] ?? 9;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
export const flagConditionFilters = [
|
||||
{
|
||||
|
|
@ -559,10 +627,10 @@ export const flagConditionFilters = [
|
|||
icon: 'filters/userid'
|
||||
}
|
||||
].sort((a, b) => {
|
||||
const aOrder = filterOrder[a.category] ?? 9
|
||||
const bOrder = filterOrder[b.category] ?? 9
|
||||
return aOrder - bOrder
|
||||
})
|
||||
const aOrder = filterOrder[a.category] ?? 9;
|
||||
const bOrder = filterOrder[b.category] ?? 9;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
export const conditionalFilters = [
|
||||
{
|
||||
|
|
@ -612,7 +680,7 @@ export const conditionalFilters = [
|
|||
placeholder: 'Enter path or URL',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_STATUS_CODE,
|
||||
|
|
@ -622,7 +690,7 @@ export const conditionalFilters = [
|
|||
placeholder: 'Enter status code',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_METHOD,
|
||||
|
|
@ -643,8 +711,8 @@ export const conditionalFilters = [
|
|||
placeholder: 'E.g. 12',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
icon: 'filters/fetch'
|
||||
}
|
||||
],
|
||||
icon: 'filters/fetch',
|
||||
isEvent: true
|
||||
|
|
@ -667,7 +735,7 @@ export const conditionalFilters = [
|
|||
label: 'Duration',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: "filters/duration",
|
||||
icon: 'filters/duration',
|
||||
isEvent: false
|
||||
},
|
||||
{
|
||||
|
|
@ -690,10 +758,10 @@ export const conditionalFilters = [
|
|||
icon: 'filters/userid'
|
||||
}
|
||||
].sort((a, b) => {
|
||||
const aOrder = filterOrder[a.category] ?? 9
|
||||
const bOrder = filterOrder[b.category] ?? 9
|
||||
return aOrder - bOrder
|
||||
})
|
||||
const aOrder = filterOrder[a.category] ?? 9;
|
||||
const bOrder = filterOrder[b.category] ?? 9;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
export const mobileConditionalFilters = [
|
||||
{
|
||||
|
|
@ -703,7 +771,7 @@ export const mobileConditionalFilters = [
|
|||
label: 'Duration',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: "filters/duration",
|
||||
icon: 'filters/duration',
|
||||
isEvent: false
|
||||
},
|
||||
{
|
||||
|
|
@ -721,7 +789,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'Enter path or URL',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_STATUS_CODE,
|
||||
|
|
@ -731,7 +799,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'Enter status code',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_METHOD,
|
||||
|
|
@ -752,8 +820,8 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'E.g. 12',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
icon: 'filters/fetch'
|
||||
}
|
||||
],
|
||||
icon: 'filters/fetch',
|
||||
isEvent: true
|
||||
|
|
@ -779,11 +847,11 @@ export const mobileConditionalFilters = [
|
|||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: 'filters/cpu-load',
|
||||
options: [
|
||||
{ label: 'nominal', value: "0" },
|
||||
{ label: 'warm', value: "1" },
|
||||
{ label: 'hot', value: "2" },
|
||||
{ label: 'critical', value: "3" }
|
||||
],
|
||||
{ label: 'nominal', value: '0' },
|
||||
{ label: 'warm', value: '1' },
|
||||
{ label: 'hot', value: '2' },
|
||||
{ label: 'critical', value: '3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'mainThreadCPU',
|
||||
|
|
@ -793,7 +861,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: '0 .. 100',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: 'filters/cpu-load',
|
||||
icon: 'filters/cpu-load'
|
||||
},
|
||||
{
|
||||
key: 'viewComponent',
|
||||
|
|
@ -803,7 +871,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'View Name',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: 'filters/view',
|
||||
icon: 'filters/view'
|
||||
},
|
||||
{
|
||||
key: FilterKey.USERID,
|
||||
|
|
@ -833,7 +901,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'logged value',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/console',
|
||||
icon: 'filters/console'
|
||||
},
|
||||
{
|
||||
key: 'clickEvent',
|
||||
|
|
@ -854,7 +922,7 @@ export const mobileConditionalFilters = [
|
|||
operatorOptions: filterOptions.customOperators,
|
||||
icon: 'filters/memory-load'
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
export const eventKeys = filters.filter((i) => i.isEvent).map(i => i.key);
|
||||
export const nonFlagFilters = filters.filter(i => {
|
||||
|
|
@ -955,12 +1023,12 @@ export const addElementToFiltersMap = (
|
|||
|
||||
export const addOptionsToFilter = (
|
||||
key,
|
||||
options,
|
||||
options
|
||||
) => {
|
||||
if (filtersMap[key] && filtersMap[key].options) {
|
||||
filtersMap[key].options = options
|
||||
filtersMap[key].options = options;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getMetadataLabel(key) {
|
||||
return key.replace(/^_/, '').charAt(0).toUpperCase() + key.slice(2);
|
||||
|
|
@ -1008,11 +1076,11 @@ export const addElementToConditionalFiltersMap = (
|
|||
|
||||
export const addElementToMobileConditionalFiltersMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
key,
|
||||
type = FilterType.MULTIPLE,
|
||||
operator = 'is',
|
||||
operatorOptions = filterOptions.stringOperators,
|
||||
icon = 'filters/metadata'
|
||||
key,
|
||||
type = FilterType.MULTIPLE,
|
||||
operator = 'is',
|
||||
operatorOptions = filterOptions.stringOperators,
|
||||
icon = 'filters/metadata'
|
||||
) => {
|
||||
mobileConditionalFiltersMap[key] = {
|
||||
key,
|
||||
|
|
@ -1023,8 +1091,8 @@ export const addElementToMobileConditionalFiltersMap = (
|
|||
operatorOptions,
|
||||
icon,
|
||||
isLive: true
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const addElementToLiveFiltersMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
|
|
@ -1094,7 +1162,7 @@ export default Record({
|
|||
_filter = filtersMap[`_${filter.source}`];
|
||||
} else {
|
||||
if (filtersMap[filter.key]) {
|
||||
_filter = filtersMap[filter.key]
|
||||
_filter = filtersMap[filter.key];
|
||||
} else {
|
||||
_filter = filtersMap[type];
|
||||
}
|
||||
|
|
@ -1118,14 +1186,35 @@ export default Record({
|
|||
}
|
||||
});
|
||||
|
||||
const WEB_EXCLUDE = [
|
||||
FilterKey.CLICK_MOBILE, FilterKey.SWIPE_MOBILE, FilterKey.INPUT_MOBILE,
|
||||
FilterKey.VIEW_MOBILE, FilterKey.CUSTOM_MOBILE, FilterKey.REQUEST_MOBILE, FilterKey.ERROR_MOBILE
|
||||
];
|
||||
|
||||
const MOBILE_EXCLUDE = [
|
||||
FilterKey.CLICK, FilterKey.INPUT, FilterKey.ERROR, FilterKey.CUSTOM,
|
||||
FilterKey.LOCATION, FilterKey.FETCH, FilterKey.DOM_COMPLETE,
|
||||
FilterKey.LARGEST_CONTENTFUL_PAINT_TIME, FilterKey.TTFB, FilterKey.USER_BROWSER,
|
||||
FilterKey.PLATFORM
|
||||
];
|
||||
|
||||
/**
|
||||
* Group filters by category
|
||||
* @param {*} filtersMap
|
||||
* @returns
|
||||
* @param map
|
||||
* @param isMobile
|
||||
*/
|
||||
export const generateFilterOptions = (map) => {
|
||||
export const generateFilterOptions = (map, isMobile = false) => {
|
||||
const filterSection = {};
|
||||
Object.keys(map).forEach(key => {
|
||||
if (isMobile && MOBILE_EXCLUDE.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMobile && WEB_EXCLUDE.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = map[key];
|
||||
if (filterSection.hasOwnProperty(filter.category)) {
|
||||
filterSection[filter.category].push(filter);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: chalice
|
||||
description: A Helm chart for Kubernetes
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
|
|
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
|
|||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.7
|
||||
|
||||
version: 0.1.12
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
AppVersion: "v1.19.0"
|
||||
AppVersion: "v1.19.12"
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ spec:
|
|||
value: {{ .Values.global.s3.assistRecordsBucket }}
|
||||
- name: sessions_bucket
|
||||
value: {{ .Values.global.s3.recordingsBucket }}
|
||||
- name: IOS_VIDEO_BUCKET
|
||||
value: {{ .Values.global.s3.recordingsBucket }}
|
||||
- name: sourcemaps_bucket
|
||||
value: {{ .Values.global.s3.sourcemapsBucket }}
|
||||
- name: js_cache_bucket
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: db
|
||||
description: A Helm chart for Kubernetes
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
|
|
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
|
|||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.1
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
AppVersion: "v1.19.0"
|
||||
AppVersion: "v1.19.2"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: frontend
|
||||
description: A Helm chart for Kubernetes
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
|
|
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
|
|||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (frontends://semver.org/)
|
||||
version: 0.1.10
|
||||
|
||||
version: 0.1.16
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
AppVersion: "v1.19.0"
|
||||
AppVersion: "v1.19.9"
|
||||
|
|
|
|||
|
|
@ -5,9 +5,25 @@ cd $(dirname $0)
|
|||
|
||||
is_migrate=$1
|
||||
|
||||
# Check if the openreplay version is set.
|
||||
# This will take precedence over the .Values.fromVersion variable
|
||||
# Because its created by installation programatically.
|
||||
if [[ -n $OPENREPLAY_VERSION ]]; then
|
||||
is_migrate=true
|
||||
PREVIOUS_APP_VERSION=$OPENREPLAY_VERSION
|
||||
echo "$OPENREPLAY_VERSION set"
|
||||
fi
|
||||
|
||||
if [[ $FORCE_MIGRATION == "true" ]]; then
|
||||
is_migrate=true
|
||||
fi
|
||||
|
||||
# Passed from env
|
||||
# PREVIOUS_APP_VERSION
|
||||
# CHART_APP_VERSION
|
||||
# Converting alphaneumeric to number.
|
||||
PREVIOUS_APP_VERSION=$(echo $PREVIOUS_APP_VERSION | cut -d "v" -f2)
|
||||
CHART_APP_VERSION=$(echo $CHART_APP_VERSION | cut -d "v" -f2)
|
||||
|
||||
function migration() {
|
||||
ls -la /opt/openreplay/openreplay
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ RETENTION_TIME=${RETENTION_TIME:-345600000}
|
|||
topics=(
|
||||
"raw"
|
||||
"raw-ios"
|
||||
"raw-images"
|
||||
"canvas-images"
|
||||
"trigger"
|
||||
"canvas-trigger"
|
||||
"mobile-trigger"
|
||||
"cache"
|
||||
"analytics"
|
||||
"storage-failover"
|
||||
|
|
|
|||
33
scripts/helmcharts/openreplay/templates/configmap.yaml
Normal file
33
scripts/helmcharts/openreplay/templates/configmap.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: openreplay-version
|
||||
namespace: "{{ .Release.Namespace }}"
|
||||
annotations:
|
||||
"helm.sh/hook": post-install, post-upgrade
|
||||
"helm.sh/hook-weight": "-6" # Higher precidence, so the first the config map will get created.
|
||||
data:
|
||||
version: {{ .Chart.AppVersion }}
|
||||
---
|
||||
# If some jobs or crons are doing db operations, or using credentias,
|
||||
# it should fetch them from this secret.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: openreplay-secrets
|
||||
namespace: "{{ .Release.Namespace }}"
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install, pre-upgrade
|
||||
"helm.sh/hook-weight": "-6" # Higher precidence, so the first the config map will get created.
|
||||
"helm.sh/hook-delete-policy": "before-hook-creation"
|
||||
data:
|
||||
PGHOST: "{{ .Values.global.postgresql.postgresqlHost | b64enc }}"
|
||||
PGPORT: "{{ .Values.global.postgresql.postgresqlPort | b64enc }}"
|
||||
PGDATABASE: "{{ .Values.global.postgresql.postgresqlDatabase | b64enc }}"
|
||||
PGUSER: "{{ .Values.global.postgresql.postgresqlUser | b64enc }}"
|
||||
PGPASSWORD: "{{ .Values.global.postgresql.postgresqlPassword | b64enc }}"
|
||||
CLICKHOUSE_USER: "{{ .Values.global.clickhouse.username | b64enc }}"
|
||||
CLICKHOUSE_PASSWORD: "{{ .Values.global.clickhouse.password | b64enc }}"
|
||||
MINIO_HOST: "{{ .Values.global.s3.endpoint | b64enc }}"
|
||||
MINIO_ACCESS_KEY: "{{ .Values.global.s3.accessKey | b64enc }}"
|
||||
MINIO_SECRET_KEY: "{{ .Values.global.s3.secretKey | b64enc }}"
|
||||
|
|
@ -2,9 +2,15 @@
|
|||
Don't have to trigger migration if there is no version change
|
||||
Don't have to trigger migration if skipMigration is set
|
||||
Have to trigger migration if forceMigration is set
|
||||
|
||||
versionChange is true when:
|
||||
Release.IsUpgrade is false.
|
||||
Or .Values.deployment.argo is set.
|
||||
Or Release.IsUpgrade is true and .Values.fromVersion is not equal to .Chart.AppVersion.
|
||||
*/}}
|
||||
{{- $versionChange := and (eq .Values.fromVersion .Chart.AppVersion) (.Release.IsUpgrade) }}
|
||||
{{- if or (not (or .Values.skipMigration $versionChange)) .Values.forceMigration }}
|
||||
|
||||
{{- $versionChange := (or (not .Release.IsUpgrade) .Values.deployment.argo (and .Release.IsUpgrade (not (eq .Values.fromVersion .Chart.AppVersion)))) }}
|
||||
{{- if or .Values.forceMigration (and (not .Values.skipMigration) $versionChange) }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
|
|
@ -70,9 +76,26 @@ spec:
|
|||
- |
|
||||
set -x
|
||||
mkdir -p /opt/openreplay/openreplay && cd /opt/openreplay/openreplay
|
||||
git clone {{ .Values.global.dbMigrationUpstreamRepoURL | default "https://github.com/openreplay/openreplay" }} .
|
||||
|
||||
# Function to check if GitHub is available
|
||||
check_github() {
|
||||
for i in {1..10}; do
|
||||
if ping -c 1 github.com &> /dev/null; then
|
||||
echo "GitHub is available."
|
||||
git clone {{ .Values.global.dbMigrationUpstreamRepoURL | default "https://github.com/openreplay/openreplay" }} .
|
||||
break
|
||||
else
|
||||
echo "GitHub is not available. Retrying in 3 seconds..."
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_github
|
||||
|
||||
ls /opt/openreplay/openreplay
|
||||
git checkout {{ default .Chart.AppVersion .Values.dbMigrationUpstreamBranch }} || exit 10
|
||||
git log -1
|
||||
{{ .Values.global.dbMigrationPreCommand | default "" }}
|
||||
|
||||
exit_count=0
|
||||
|
|
@ -92,7 +115,7 @@ spec:
|
|||
done
|
||||
|
||||
if [ $error_connection -eq 1 ]; then
|
||||
echo "[error] clickhouse is not running. Check kubectl get po -n db; exiting"
|
||||
echo "[error] postgres is not running. Check kubectl get po -n db; exiting"
|
||||
exit 100
|
||||
fi
|
||||
|
||||
|
|
@ -132,6 +155,12 @@ spec:
|
|||
containers:
|
||||
- name: postgres
|
||||
env:
|
||||
- name: OPENREPLAY_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: openreplay-version
|
||||
key: version
|
||||
optional: true
|
||||
- name: FORCE_MIGRATION
|
||||
value: "{{ .Values.forceMigration }}"
|
||||
- name: PREVIOUS_APP_VERSION
|
||||
|
|
@ -178,6 +207,12 @@ spec:
|
|||
- name: minio
|
||||
image: bitnami/minio:2023.11.20
|
||||
env:
|
||||
- name: OPENREPLAY_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: openreplay-version
|
||||
key: version
|
||||
optional: true
|
||||
{{- range $key, $val := .Values.global.env }}
|
||||
- name: {{ $key }}
|
||||
value: '{{ $val }}'
|
||||
|
|
@ -301,6 +336,12 @@ spec:
|
|||
- name: clickhouse
|
||||
image: clickhouse/clickhouse-server:22.12-alpine
|
||||
env:
|
||||
- name: OPENREPLAY_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: openreplay-version
|
||||
key: version
|
||||
optional: true
|
||||
{{- range $key, $val := .Values.global.env }}
|
||||
- name: {{ $key }}
|
||||
value: '{{ $val }}'
|
||||
|
|
@ -336,6 +377,12 @@ spec:
|
|||
- name: kafka
|
||||
image: bitnami/kafka:2.6.0-debian-10-r30
|
||||
env:
|
||||
- name: OPENREPLAY_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: openreplay-version
|
||||
key: version
|
||||
optional: true
|
||||
{{- range $key, $val := .Values.global.env }}
|
||||
- name: {{ $key }}
|
||||
value: '{{ $val }}'
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ migrationJob:
|
|||
migration:
|
||||
env: {}
|
||||
|
||||
deployment:
|
||||
argo: false
|
||||
forceMigration: false
|
||||
skipMigration: false
|
||||
|
||||
redis: &redis
|
||||
tls:
|
||||
enabled: false
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ $fn_def$, :'next_version')
|
|||
|
||||
--
|
||||
ALTER TABLE IF EXISTS events.clicks
|
||||
ADD COLUMN IF NOT EXISTS normalized_x smallint NULL,
|
||||
ADD COLUMN IF NOT EXISTS normalized_y smallint NULL,
|
||||
ADD COLUMN IF NOT EXISTS normalized_x decimal NULL,
|
||||
ADD COLUMN IF NOT EXISTS normalized_y decimal NULL,
|
||||
DROP COLUMN IF EXISTS x,
|
||||
DROP COLUMN IF EXISTS y;
|
||||
|
||||
|
|
|
|||
|
|
@ -620,16 +620,16 @@ CREATE INDEX pages_query_nn_gin_idx ON events.pages USING GIN (query gin_trgm_op
|
|||
|
||||
CREATE TABLE events.clicks
|
||||
(
|
||||
session_id bigint NOT NULL REFERENCES public.sessions (session_id) ON DELETE CASCADE,
|
||||
message_id bigint NOT NULL,
|
||||
timestamp bigint NOT NULL,
|
||||
label text DEFAULT NULL,
|
||||
url text DEFAULT '' NOT NULL,
|
||||
session_id bigint NOT NULL REFERENCES public.sessions (session_id) ON DELETE CASCADE,
|
||||
message_id bigint NOT NULL,
|
||||
timestamp bigint NOT NULL,
|
||||
label text DEFAULT NULL,
|
||||
url text DEFAULT '' NOT NULL,
|
||||
path text,
|
||||
selector text DEFAULT '' NOT NULL,
|
||||
hesitation integer DEFAULT NULL,
|
||||
normalized_x smallint DEFAULT NULL,
|
||||
normalized_y smallint DEFAULT NULL,
|
||||
selector text DEFAULT '' NOT NULL,
|
||||
hesitation integer DEFAULT NULL,
|
||||
normalized_x decimal DEFAULT NULL,
|
||||
normalized_y decimal DEFAULT NULL,
|
||||
PRIMARY KEY (session_id, message_id)
|
||||
);
|
||||
CREATE INDEX clicks_session_id_idx ON events.clicks (session_id);
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ returns `result` without changes.
|
|||
|
||||
```js
|
||||
import Tracker from '@openreplay/tracker';
|
||||
import trackerGraphQL from '@openreplay/tracker-graphql';
|
||||
import { createGraphqlMiddleware } from '@openreplay/tracker-graphql';
|
||||
|
||||
const tracker = new Tracker({
|
||||
projectKey: YOUR_PROJECT_KEY,
|
||||
});
|
||||
|
||||
export const recordGraphQL = tracker.plugin(trackerGraphQL());
|
||||
export const recordGraphQL = tracker.use(createGraphqlMiddleware());
|
||||
```
|
||||
|
||||
### Relay
|
||||
|
|
@ -33,15 +33,28 @@ If you're using [Relay network tools](https://github.com/relay-tools/react-relay
|
|||
you can simply [create a middleware](https://github.com/relay-tools/react-relay-network-modern/tree/master?tab=readme-ov-file#example-of-injecting-networklayer-with-middlewares-on-the-client-side)
|
||||
|
||||
```js
|
||||
import { createRelayMiddleware } from '@openreplay/tracker-graphql'
|
||||
import { createRelayMiddleware } from '@openreplay/tracker-graphql';
|
||||
|
||||
const trackerMiddleware = createRelayMiddleware(tracker)
|
||||
const trackerMiddleware = tracker.use(createRelayMiddleware());
|
||||
|
||||
const network = new RelayNetworkLayer([
|
||||
// your middleware
|
||||
// ,
|
||||
trackerMiddleware
|
||||
])
|
||||
trackerMiddleware,
|
||||
]);
|
||||
```
|
||||
|
||||
You can pass a Sanitizer function to `createRelayMiddleware` to sanitize the variables and data before sending them to OpenReplay.
|
||||
|
||||
```js
|
||||
const trackerLink = tracker.use(
|
||||
createRelayMiddleware((variables) => {
|
||||
return {
|
||||
...variables,
|
||||
password: '***',
|
||||
};
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Or you can manually put `recordGraphQL` call
|
||||
|
|
@ -52,22 +65,22 @@ then you should do something like below
|
|||
import { createGraphqlMiddleware } from '@openreplay/tracker-graphql'; // see above for recordGraphQL definition
|
||||
import { Environment } from 'relay-runtime';
|
||||
|
||||
const handler = createGraphqlMiddleware(tracker)
|
||||
const handler = tracker.use(createGraphqlMiddleware());
|
||||
|
||||
function fetchQuery(operation, variables, cacheConfig, uploadables) {
|
||||
return fetch('www.myapi.com/resource', {
|
||||
// ...
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result =>
|
||||
handler(
|
||||
// op kind, name, variables, response, duration (default 0)
|
||||
operation.operationKind,
|
||||
operation.name,
|
||||
variables,
|
||||
result,
|
||||
duration,
|
||||
),
|
||||
.then((response) => response.json())
|
||||
.then((result) =>
|
||||
handler(
|
||||
// op kind, name, variables, response, duration (default 0)
|
||||
operation.operationKind,
|
||||
operation.name,
|
||||
variables,
|
||||
result,
|
||||
duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -81,10 +94,23 @@ See [Relay Network Layer](https://relay.dev/docs/en/network-layer) for details.
|
|||
For [Apollo](https://www.apollographql.com/) you should create a new `ApolloLink`
|
||||
|
||||
```js
|
||||
import { createTrackerLink } from '@openreplay/tracker-graphql'
|
||||
import { createTrackerLink } from '@openreplay/tracker-graphql';
|
||||
|
||||
const trackerLink = createTrackerLink(tracker);
|
||||
const yourLink = new ApolloLink(trackerLink)
|
||||
const trackerLink = tracker.use(createTrackerLink());
|
||||
const yourLink = new ApolloLink(trackerLink);
|
||||
```
|
||||
|
||||
You can pass a Sanitizer function to `createRelayMiddleware` to sanitize the variables and data before sending them to OpenReplay.
|
||||
|
||||
```js
|
||||
const trackerLink = tracker.use(
|
||||
createTrackerLink((variables) => {
|
||||
return {
|
||||
...variables,
|
||||
password: '***',
|
||||
};
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Alternatively you can use generic graphql handler:
|
||||
|
|
@ -93,18 +119,21 @@ Alternatively you can use generic graphql handler:
|
|||
import { createGraphqlMiddleware } from '@openreplay/tracker-graphql'; // see above for recordGraphQL definition
|
||||
import { ApolloLink } from 'apollo-link';
|
||||
|
||||
const handler = createGraphqlMiddleware(tracker)
|
||||
const handler = tracker.use(createGraphqlMiddleware());
|
||||
|
||||
const trackerApolloLink = new ApolloLink((operation, forward) => {
|
||||
return forward(operation).map(result =>
|
||||
handler(
|
||||
operation.setContext({ start: performance.now() });
|
||||
return forward(operation).map((result) => {
|
||||
const time = performance.now() - operation.getContext().start;
|
||||
return handler(
|
||||
// op kind, name, variables, response, duration (default 0)
|
||||
operation.query.definitions[0].operation,
|
||||
operation.operationName,
|
||||
operation.variables,
|
||||
result,
|
||||
),
|
||||
);
|
||||
time,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const link = ApolloLink.from([
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { App, Messages } from '@openreplay/tracker';
|
||||
import Observable from 'zen-observable';
|
||||
import { Sanitizer } from './types';
|
||||
|
||||
type Operation = {
|
||||
query: Record<string, any>;
|
||||
|
|
@ -9,48 +10,63 @@ type Operation = {
|
|||
};
|
||||
type NextLink = (operation: Operation) => Observable<Record<string, any>>;
|
||||
|
||||
export const createTrackerLink = (app: App | null) => {
|
||||
if (!app) {
|
||||
return (operation: Operation, forward: NextLink) => forward(operation);
|
||||
}
|
||||
return (operation: Operation, forward: NextLink) => {
|
||||
return new Observable((observer) => {
|
||||
const start = app.timestamp();
|
||||
const observable = forward(operation);
|
||||
const subscription = observable.subscribe({
|
||||
next(value) {
|
||||
const end = app.timestamp();
|
||||
app.send(
|
||||
Messages.GraphQL(
|
||||
operation.query.definitions[0].kind,
|
||||
operation.operationName,
|
||||
JSON.stringify(operation.variables),
|
||||
JSON.stringify(value.data),
|
||||
end - start,
|
||||
),
|
||||
);
|
||||
observer.next(value);
|
||||
},
|
||||
error(error) {
|
||||
const end = app.timestamp();
|
||||
app.send(
|
||||
Messages.GraphQL(
|
||||
operation.query.definitions[0].kind,
|
||||
operation.operationName,
|
||||
JSON.stringify(operation.variables),
|
||||
JSON.stringify(error),
|
||||
end - start,
|
||||
),
|
||||
);
|
||||
observer.error(error);
|
||||
},
|
||||
complete() {
|
||||
observer.complete();
|
||||
},
|
||||
});
|
||||
export const createTrackerLink = (
|
||||
sanitizer?: Sanitizer<Record<string, any> | undefined | null>,
|
||||
) => {
|
||||
return (app: App | null) => {
|
||||
if (!app) {
|
||||
return (operation: Operation, forward: NextLink) => forward(operation);
|
||||
}
|
||||
return (operation: Operation, forward: NextLink) => {
|
||||
return new Observable((observer) => {
|
||||
const start = app.timestamp();
|
||||
const observable = forward(operation);
|
||||
const subscription = observable.subscribe({
|
||||
next(value) {
|
||||
const end = app.timestamp();
|
||||
const operationDefinition = operation.query.definitions[0];
|
||||
app.send(
|
||||
Messages.GraphQL(
|
||||
operationDefinition.kind === 'OperationDefinition'
|
||||
? operationDefinition.operation
|
||||
: 'unknown?',
|
||||
operation.operationName,
|
||||
JSON.stringify(
|
||||
sanitizer
|
||||
? sanitizer(operation.variables)
|
||||
: operation.variables,
|
||||
),
|
||||
JSON.stringify(sanitizer ? sanitizer(value.data) : value.data),
|
||||
end - start,
|
||||
),
|
||||
);
|
||||
observer.next(value);
|
||||
},
|
||||
error(error) {
|
||||
const end = app.timestamp();
|
||||
app.send(
|
||||
Messages.GraphQL(
|
||||
operation.query.definitions[0].kind,
|
||||
operation.operationName,
|
||||
JSON.stringify(
|
||||
sanitizer
|
||||
? sanitizer(operation.variables)
|
||||
: operation.variables,
|
||||
),
|
||||
JSON.stringify(error),
|
||||
end - start,
|
||||
),
|
||||
);
|
||||
observer.error(error);
|
||||
},
|
||||
complete() {
|
||||
observer.complete();
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, Messages } from "@openreplay/tracker";
|
||||
import { App, Messages } from '@openreplay/tracker';
|
||||
|
||||
function createGraphqlMiddleware() {
|
||||
return (app: App | null) => {
|
||||
|
|
@ -10,7 +10,7 @@ function createGraphqlMiddleware() {
|
|||
operationName: string,
|
||||
variables: any,
|
||||
result: any,
|
||||
duration = 0
|
||||
duration = 0,
|
||||
) => {
|
||||
try {
|
||||
app.send(
|
||||
|
|
@ -30,4 +30,4 @@ function createGraphqlMiddleware() {
|
|||
};
|
||||
}
|
||||
|
||||
export default createGraphqlMiddleware
|
||||
export default createGraphqlMiddleware;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import createTrackerLink from './apolloMiddleware.js';
|
||||
import createRelayMiddleware from './relayMiddleware.js';
|
||||
import createGraphqlMiddleware from './graphqlMiddleware.js';
|
||||
import { Sanitizer } from './types.js';
|
||||
|
||||
export {
|
||||
createTrackerLink,
|
||||
createRelayMiddleware,
|
||||
createGraphqlMiddleware,
|
||||
}
|
||||
Sanitizer,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,37 +1,55 @@
|
|||
import { App, Messages } from '@openreplay/tracker';
|
||||
import type { Middleware, RelayRequest } from './relaytypes';
|
||||
import { Sanitizer } from './types';
|
||||
|
||||
const createRelayMiddleware = (app: App | null): Middleware => {
|
||||
if (!app) {
|
||||
return (next) => async (req) => await next(req);
|
||||
}
|
||||
return (next) => async (req) => {
|
||||
const start = app.timestamp();
|
||||
const resp = await next(req)
|
||||
const end = app.timestamp();
|
||||
if ('requests' in req) {
|
||||
req.requests.forEach((request) => {
|
||||
app.send(getMessage(request, resp.json as Record<string, any>, end - start))
|
||||
})
|
||||
} else {
|
||||
app.send(getMessage(req, resp.json as Record<string, any>, end - start))
|
||||
const createRelayMiddleware = (sanitizer?: Sanitizer<Record<string, any>>) => {
|
||||
return (app: App | null): Middleware => {
|
||||
if (!app) {
|
||||
return (next) => async (req) => await next(req);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
return (next) => async (req) => {
|
||||
const start = app.timestamp();
|
||||
const resp = await next(req);
|
||||
const end = app.timestamp();
|
||||
if ('requests' in req) {
|
||||
req.requests.forEach((request) => {
|
||||
app.send(
|
||||
getMessage(
|
||||
request,
|
||||
resp.json as Record<string, any>,
|
||||
end - start,
|
||||
sanitizer,
|
||||
),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
app.send(
|
||||
getMessage(
|
||||
req,
|
||||
resp.json as Record<string, any>,
|
||||
end - start,
|
||||
sanitizer,
|
||||
),
|
||||
);
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function getMessage(request: RelayRequest, json: Record<string, any>, duration: number) {
|
||||
function getMessage(
|
||||
request: RelayRequest,
|
||||
json: Record<string, any>,
|
||||
duration: number,
|
||||
sanitizer?: Sanitizer<Record<string, any>>,
|
||||
) {
|
||||
const opKind = request.operation.kind;
|
||||
const opName = request.operation.name;
|
||||
const vars = JSON.stringify(request.variables)
|
||||
const opResp = JSON.stringify(json)
|
||||
return Messages.GraphQL(
|
||||
opKind,
|
||||
opName,
|
||||
vars,
|
||||
opResp,
|
||||
duration
|
||||
)
|
||||
const vars = JSON.stringify(
|
||||
sanitizer ? sanitizer(request.variables) : request.variables,
|
||||
);
|
||||
const opResp = JSON.stringify(sanitizer ? sanitizer(json) : json);
|
||||
return Messages.GraphQL(opKind, opName, vars, opResp, duration);
|
||||
}
|
||||
|
||||
export default createRelayMiddleware
|
||||
export default createRelayMiddleware;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
type ConcreteBatch = {
|
||||
kind: 'Batch';
|
||||
fragment: any;
|
||||
|
|
@ -9,7 +8,7 @@ type ConcreteBatch = {
|
|||
text: string | null;
|
||||
operationKind: string;
|
||||
};
|
||||
type Variables = { [name: string]: any };
|
||||
export type Variables = { [name: string]: any };
|
||||
interface FetchOpts {
|
||||
url?: string;
|
||||
method: 'POST' | 'GET';
|
||||
|
|
@ -17,7 +16,13 @@ interface FetchOpts {
|
|||
body: string | FormData;
|
||||
credentials?: 'same-origin' | 'include' | 'omit';
|
||||
mode?: 'cors' | 'websocket' | 'navigate' | 'no-cors' | 'same-origin';
|
||||
cache?: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache' | 'only-if-cached';
|
||||
cache?:
|
||||
| 'default'
|
||||
| 'no-store'
|
||||
| 'reload'
|
||||
| 'no-cache'
|
||||
| 'force-cache'
|
||||
| 'only-if-cached';
|
||||
redirect?: 'follow' | 'error' | 'manual';
|
||||
signal?: AbortSignal;
|
||||
[name: string]: any;
|
||||
|
|
|
|||
1
tracker/tracker-graphql/src/types.ts
Normal file
1
tracker/tracker-graphql/src/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Sanitizer<T> = (values: T) => Partial<T>;
|
||||
|
|
@ -52,6 +52,12 @@ export interface StartOptions {
|
|||
forceNew?: boolean
|
||||
sessionHash?: string
|
||||
assistOnly?: boolean
|
||||
/**
|
||||
* @deprecated We strongly advise to use .start().then instead.
|
||||
*
|
||||
* This method is kept for snippet compatibility only
|
||||
* */
|
||||
startCallback?: (result: StartPromiseReturn) => void
|
||||
}
|
||||
|
||||
interface OnStartInfo {
|
||||
|
|
@ -161,6 +167,12 @@ type AppOptions = {
|
|||
}
|
||||
|
||||
network?: NetworkOptions
|
||||
/**
|
||||
* use this flag if you're using Angular
|
||||
* basically goes around window.Zone api changes to mutation observer
|
||||
* and event listeners
|
||||
* */
|
||||
angularMode?: boolean
|
||||
} & WebworkerOptions &
|
||||
SessOptions
|
||||
|
||||
|
|
@ -185,12 +197,14 @@ const proto = {
|
|||
resp: 'never-gonna-let-you-down',
|
||||
// regenerating id (copied other tab)
|
||||
reg: 'never-gonna-run-around-and-desert-you',
|
||||
// tracker inside a child iframe
|
||||
iframeSignal: 'never-gonna-make-you-cry',
|
||||
// getting node id for child iframe
|
||||
iframeId: 'never-gonna-say-goodbye',
|
||||
// batch of messages from an iframe window
|
||||
iframeBatch: 'never-gonna-tell-a-lie-and-hurt-you',
|
||||
iframeSignal: 'tracker inside a child iframe',
|
||||
iframeId: 'getting node id for child iframe',
|
||||
iframeBatch: 'batch of messages from an iframe window',
|
||||
parentAlive: 'signal that parent is live',
|
||||
killIframe: 'stop tracker inside frame',
|
||||
startIframe: 'start tracker inside frame',
|
||||
// checking updates
|
||||
polling: 'hello-how-are-you-im-under-the-water-please-help-me',
|
||||
} as const
|
||||
|
||||
export default class App {
|
||||
|
|
@ -237,14 +251,17 @@ export default class App {
|
|||
private rootId: number | null = null
|
||||
private pageFrames: HTMLIFrameElement[] = []
|
||||
private frameOderNumber = 0
|
||||
private readonly initialHostName = location.hostname
|
||||
private features = {
|
||||
'feature-flags': true,
|
||||
'usability-test': true,
|
||||
}
|
||||
|
||||
constructor(
|
||||
projectKey: string,
|
||||
sessionToken: string | undefined,
|
||||
options: Partial<Options>,
|
||||
private readonly signalError: (error: string, apis: string[]) => void,
|
||||
private readonly insideIframe: boolean,
|
||||
public readonly insideIframe: boolean,
|
||||
) {
|
||||
this.contextId = Math.random().toString(36).slice(2)
|
||||
this.projectKey = projectKey
|
||||
|
|
@ -301,6 +318,7 @@ export default class App {
|
|||
__save_canvas_locally: false,
|
||||
useAnimationFrame: false,
|
||||
},
|
||||
angularMode: false,
|
||||
}
|
||||
this.options = simpleMerge(defaultOptions, options)
|
||||
|
||||
|
|
@ -318,7 +336,7 @@ export default class App {
|
|||
this.localStorage = this.options.localStorage ?? window.localStorage
|
||||
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage
|
||||
this.sanitizer = new Sanitizer(this, options)
|
||||
this.nodes = new Nodes(this.options.node_id)
|
||||
this.nodes = new Nodes(this.options.node_id, Boolean(options.angularMode))
|
||||
this.observer = new Observer(this, options)
|
||||
this.ticker = new Ticker(this)
|
||||
this.ticker.attach(() => this.commit())
|
||||
|
|
@ -344,136 +362,31 @@ export default class App {
|
|||
this.session.applySessionHash(sessionToken)
|
||||
}
|
||||
|
||||
this.initWorker()
|
||||
|
||||
const thisTab = this.session.getTabId()
|
||||
|
||||
if (this.insideIframe) {
|
||||
/**
|
||||
* listen for messages from parent window, so we can signal that we're alive
|
||||
* */
|
||||
window.addEventListener('message', this.parentCrossDomainFrameListener)
|
||||
setInterval(() => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.polling,
|
||||
context: this.contextId,
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}, 250)
|
||||
} else {
|
||||
this.initWorker()
|
||||
}
|
||||
if (!this.insideIframe) {
|
||||
/**
|
||||
* if we get a signal from child iframes, we check for their node_id and send it back,
|
||||
* so they can act as if it was just a same-domain iframe
|
||||
* */
|
||||
let crossdomainFrameCount = 0
|
||||
const catchIframeMessage = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
if (data.line === proto.iframeSignal) {
|
||||
const childIframeDomain = data.domain
|
||||
const pageIframes = Array.from(document.querySelectorAll('iframe'))
|
||||
this.pageFrames = pageIframes
|
||||
const signalId = async () => {
|
||||
let tries = 0
|
||||
while (tries < 10) {
|
||||
const id = this.checkNodeId(pageIframes, childIframeDomain)
|
||||
if (id) {
|
||||
this.waitStarted()
|
||||
.then(() => {
|
||||
crossdomainFrameCount++
|
||||
const token = this.session.getSessionToken()
|
||||
const iframeData = {
|
||||
line: proto.iframeId,
|
||||
context: this.contextId,
|
||||
domain: childIframeDomain,
|
||||
id,
|
||||
token,
|
||||
frameOrderNumber: crossdomainFrameCount,
|
||||
}
|
||||
this.debug.log('iframe_data', iframeData)
|
||||
// @ts-ignore
|
||||
event.source?.postMessage(iframeData, '*')
|
||||
})
|
||||
.catch(console.error)
|
||||
tries = 10
|
||||
break
|
||||
}
|
||||
tries++
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
void signalId()
|
||||
}
|
||||
/**
|
||||
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
|
||||
* plus we rewrite some of the messages to be relative to the main context/window
|
||||
* */
|
||||
if (data.line === proto.iframeBatch) {
|
||||
const msgBatch = data.messages
|
||||
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
|
||||
if (msg[0] === MType.MouseMove) {
|
||||
let fixedMessage = msg
|
||||
this.pageFrames.forEach((frame) => {
|
||||
if (frame.dataset.domain === event.data.domain) {
|
||||
const [type, x, y] = msg
|
||||
const { left, top } = frame.getBoundingClientRect()
|
||||
fixedMessage = [type, x + left, y + top]
|
||||
}
|
||||
})
|
||||
return fixedMessage
|
||||
}
|
||||
if (msg[0] === MType.MouseClick) {
|
||||
let fixedMessage = msg
|
||||
this.pageFrames.forEach((frame) => {
|
||||
if (frame.dataset.domain === event.data.domain) {
|
||||
const [type, id, hesitationTime, label, selector, normX, normY] = msg
|
||||
const { left, top, width, height } = frame.getBoundingClientRect()
|
||||
|
||||
const contentWidth = document.documentElement.scrollWidth
|
||||
const contentHeight = document.documentElement.scrollHeight
|
||||
// (normalizedX * frameWidth + frameLeftOffset)/docSize
|
||||
const fullX = (normX / 100) * width + left
|
||||
const fullY = (normY / 100) * height + top
|
||||
const fixedX = fullX / contentWidth
|
||||
const fixedY = fullY / contentHeight
|
||||
|
||||
fixedMessage = [
|
||||
type,
|
||||
id,
|
||||
hesitationTime,
|
||||
label,
|
||||
selector,
|
||||
Math.round(fixedX * 1e3) / 1e1,
|
||||
Math.round(fixedY * 1e3) / 1e1,
|
||||
]
|
||||
}
|
||||
})
|
||||
return fixedMessage
|
||||
}
|
||||
return msg
|
||||
})
|
||||
this.messages.push(...mappedMessages)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', catchIframeMessage)
|
||||
this.attachStopCallback(() => {
|
||||
window.removeEventListener('message', catchIframeMessage)
|
||||
})
|
||||
} else {
|
||||
const catchParentMessage = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
if (data.line !== proto.iframeId) {
|
||||
return
|
||||
}
|
||||
this.rootId = data.id
|
||||
this.session.setSessionToken(data.token as string)
|
||||
this.frameOderNumber = data.frameOrderNumber
|
||||
this.debug.log('starting iframe tracking', data)
|
||||
this.allowAppStart()
|
||||
}
|
||||
window.addEventListener('message', catchParentMessage)
|
||||
this.attachStopCallback(() => {
|
||||
window.removeEventListener('message', catchParentMessage)
|
||||
})
|
||||
// communicating with parent window,
|
||||
// even if its crossdomain is possible via postMessage api
|
||||
const domain = this.initialHostName
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeSignal,
|
||||
source: thisTab,
|
||||
context: this.contextId,
|
||||
domain,
|
||||
},
|
||||
'*',
|
||||
)
|
||||
window.addEventListener('message', this.crossDomainIframeListener)
|
||||
}
|
||||
|
||||
if (this.bc !== null) {
|
||||
|
|
@ -484,7 +397,7 @@ export default class App {
|
|||
})
|
||||
this.startTimeout = setTimeout(() => {
|
||||
this.allowAppStart()
|
||||
}, 500)
|
||||
}, 250)
|
||||
this.bc.onmessage = (ev: MessageEvent<RickRoll>) => {
|
||||
if (ev.data.context === this.contextId) {
|
||||
return
|
||||
|
|
@ -515,8 +428,204 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
/** used by child iframes for crossdomain only */
|
||||
/** used by child iframes for crossdomain only */
|
||||
parentActive = false
|
||||
checkStatus = () => {
|
||||
return this.parentActive
|
||||
}
|
||||
parentCrossDomainFrameListener = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
if (!data || event.source === window) return
|
||||
if (data.line === proto.startIframe) {
|
||||
if (this.active()) return
|
||||
try {
|
||||
this.allowAppStart()
|
||||
void this.start()
|
||||
} catch (e) {
|
||||
console.error('children frame restart failed:', e)
|
||||
}
|
||||
}
|
||||
if (data.line === proto.parentAlive) {
|
||||
this.parentActive = true
|
||||
}
|
||||
if (data.line === proto.iframeId) {
|
||||
this.parentActive = true
|
||||
this.rootId = data.id
|
||||
this.session.setSessionToken(data.token as string)
|
||||
this.frameOderNumber = data.frameOrderNumber
|
||||
this.debug.log('starting iframe tracking', data)
|
||||
this.allowAppStart()
|
||||
}
|
||||
if (data.line === proto.killIframe) {
|
||||
if (this.active()) {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* context ids for iframes,
|
||||
* order is not so important as long as its consistent
|
||||
* */
|
||||
trackedFrames: string[] = []
|
||||
crossDomainIframeListener = (event: MessageEvent) => {
|
||||
if (!this.active() || event.source === window) return
|
||||
const { data } = event
|
||||
if (!data) return
|
||||
if (data.line === proto.iframeSignal) {
|
||||
// @ts-ignore
|
||||
event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*')
|
||||
const pageIframes = Array.from(document.querySelectorAll('iframe'))
|
||||
this.pageFrames = pageIframes
|
||||
const signalId = async () => {
|
||||
if (event.source === null) {
|
||||
return console.error('Couldnt connect to event.source for child iframe tracking')
|
||||
}
|
||||
const id = await this.checkNodeId(pageIframes, event.source)
|
||||
if (id && !this.trackedFrames.includes(data.context)) {
|
||||
try {
|
||||
this.trackedFrames.push(data.context)
|
||||
await this.waitStarted()
|
||||
const token = this.session.getSessionToken()
|
||||
const order = this.trackedFrames.findIndex((f) => f === data.context) + 1
|
||||
if (order === 0) {
|
||||
this.debug.error(
|
||||
'Couldnt get order number for iframe',
|
||||
data.context,
|
||||
this.trackedFrames,
|
||||
)
|
||||
}
|
||||
const iframeData = {
|
||||
line: proto.iframeId,
|
||||
id,
|
||||
token,
|
||||
// since indexes go from 0 we +1
|
||||
frameOrderNumber: order,
|
||||
}
|
||||
this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData)
|
||||
// @ts-ignore
|
||||
event.source?.postMessage(iframeData, '*')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
} else {
|
||||
this.debug.log('Couldnt get node id for iframe', event.source, pageIframes)
|
||||
}
|
||||
}
|
||||
void signalId()
|
||||
}
|
||||
/**
|
||||
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
|
||||
* plus we rewrite some of the messages to be relative to the main context/window
|
||||
* */
|
||||
if (data.line === proto.iframeBatch) {
|
||||
const msgBatch = data.messages
|
||||
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
|
||||
if (msg[0] === MType.MouseMove) {
|
||||
let fixedMessage = msg
|
||||
this.pageFrames.forEach((frame) => {
|
||||
if (frame.contentWindow === event.source) {
|
||||
const [type, x, y] = msg
|
||||
const { left, top } = frame.getBoundingClientRect()
|
||||
fixedMessage = [type, x + left, y + top]
|
||||
}
|
||||
})
|
||||
return fixedMessage
|
||||
}
|
||||
if (msg[0] === MType.MouseClick) {
|
||||
let fixedMessage = msg
|
||||
this.pageFrames.forEach((frame) => {
|
||||
if (frame.contentWindow === event.source) {
|
||||
const [type, id, hesitationTime, label, selector, normX, normY] = msg
|
||||
const { left, top, width, height } = frame.getBoundingClientRect()
|
||||
|
||||
const contentWidth = document.documentElement.scrollWidth
|
||||
const contentHeight = document.documentElement.scrollHeight
|
||||
// (normalizedX * frameWidth + frameLeftOffset)/docSize
|
||||
const fullX = (normX / 100) * width + left
|
||||
const fullY = (normY / 100) * height + top
|
||||
const fixedX = fullX / contentWidth
|
||||
const fixedY = fullY / contentHeight
|
||||
|
||||
fixedMessage = [
|
||||
type,
|
||||
id,
|
||||
hesitationTime,
|
||||
label,
|
||||
selector,
|
||||
Math.round(fixedX * 1e3) / 1e1,
|
||||
Math.round(fixedY * 1e3) / 1e1,
|
||||
]
|
||||
}
|
||||
})
|
||||
return fixedMessage
|
||||
}
|
||||
return msg
|
||||
})
|
||||
this.messages.push(...mappedMessages)
|
||||
}
|
||||
if (data.line === proto.polling) {
|
||||
if (!this.pollingQueue.order.length) {
|
||||
return
|
||||
}
|
||||
const nextCommand = this.pollingQueue.order[0]
|
||||
if (this.pollingQueue[nextCommand].includes(data.context)) {
|
||||
this.pollingQueue[nextCommand] = this.pollingQueue[nextCommand].filter(
|
||||
(c: string) => c !== data.context,
|
||||
)
|
||||
// @ts-ignore
|
||||
event.source?.postMessage({ line: nextCommand }, '*')
|
||||
if (this.pollingQueue[nextCommand].length === 0) {
|
||||
this.pollingQueue.order.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* { command : [remaining iframes] }
|
||||
* + order of commands
|
||||
**/
|
||||
pollingQueue: Record<string, any> = {
|
||||
order: [],
|
||||
}
|
||||
private readonly addCommand = (cmd: string) => {
|
||||
this.pollingQueue.order.push(cmd)
|
||||
this.pollingQueue[cmd] = [...this.trackedFrames]
|
||||
}
|
||||
|
||||
public bootChildrenFrames = async () => {
|
||||
await this.waitStarted()
|
||||
this.addCommand(proto.startIframe)
|
||||
}
|
||||
|
||||
public killChildrenFrames = () => {
|
||||
this.addCommand(proto.killIframe)
|
||||
}
|
||||
|
||||
signalIframeTracker = () => {
|
||||
const thisTab = this.session.getTabId()
|
||||
const signalToParent = (n: number) => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeSignal,
|
||||
source: thisTab,
|
||||
context: this.contextId,
|
||||
},
|
||||
this.options.crossdomain?.parentDomain ?? '*',
|
||||
)
|
||||
setTimeout(() => {
|
||||
if (!this.checkStatus() && n < 100) {
|
||||
void signalToParent(n + 1)
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
void signalToParent(1)
|
||||
}
|
||||
|
||||
startTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private allowAppStart() {
|
||||
public allowAppStart() {
|
||||
this.canStart = true
|
||||
if (this.startTimeout) {
|
||||
clearTimeout(this.startTimeout)
|
||||
|
|
@ -524,15 +633,38 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
private checkNodeId(iframes: HTMLIFrameElement[], domain: string) {
|
||||
private async checkNodeId(
|
||||
iframes: HTMLIFrameElement[],
|
||||
source: MessageEventSource,
|
||||
): Promise<number | null> {
|
||||
for (const iframe of iframes) {
|
||||
if (iframe.dataset.domain === domain) {
|
||||
// @ts-ignore
|
||||
return iframe[this.options.node_id] as number | undefined
|
||||
if (iframe.contentWindow && iframe.contentWindow === source) {
|
||||
/**
|
||||
* Here we're trying to get node id from the iframe (which is kept in observer)
|
||||
* because of async nature of dom initialization, we give 100 retries with 100ms delay each
|
||||
* which equals to 10 seconds. This way we have a period where we give app some time to load
|
||||
* and tracker some time to parse the initial DOM tree even on slower devices
|
||||
* */
|
||||
let tries = 0
|
||||
while (tries < 100) {
|
||||
// @ts-ignore
|
||||
const potentialId = iframe[this.options.node_id]
|
||||
if (potentialId !== undefined) {
|
||||
tries = 100
|
||||
return potentialId
|
||||
} else {
|
||||
tries++
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private initWorker() {
|
||||
try {
|
||||
this.worker = new Worker(
|
||||
|
|
@ -643,28 +775,28 @@ export default class App {
|
|||
this.messages.length = 0
|
||||
return
|
||||
}
|
||||
if (this.worker === undefined || !this.messages.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.insideIframe) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeBatch,
|
||||
messages: this.messages,
|
||||
domain: this.initialHostName,
|
||||
},
|
||||
'*',
|
||||
this.options.crossdomain?.parentDomain ?? '*',
|
||||
)
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (this.worker === undefined || !this.messages.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
requestIdleCb(() => {
|
||||
this.messages.unshift(TabData(this.session.getTabId()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()))
|
||||
// why I need to add opt chaining?
|
||||
this.worker?.postMessage(this.messages)
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
|
|
@ -736,36 +868,39 @@ export default class App {
|
|||
this.commitCallbacks.push(cb)
|
||||
}
|
||||
|
||||
attachStartCallback(cb: StartCallback, useSafe = false): void {
|
||||
attachStartCallback = (cb: StartCallback, useSafe = false): void => {
|
||||
if (useSafe) {
|
||||
cb = this.safe(cb)
|
||||
}
|
||||
this.startCallbacks.push(cb)
|
||||
}
|
||||
|
||||
attachStopCallback(cb: () => any, useSafe = false): void {
|
||||
attachStopCallback = (cb: () => any, useSafe = false): void => {
|
||||
if (useSafe) {
|
||||
cb = this.safe(cb)
|
||||
}
|
||||
this.stopCallbacks.push(cb)
|
||||
}
|
||||
|
||||
// Use app.nodes.attachNodeListener for registered nodes instead
|
||||
attachEventListener(
|
||||
attachEventListener = (
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
useSafe = true,
|
||||
useCapture = true,
|
||||
): void {
|
||||
): void => {
|
||||
if (useSafe) {
|
||||
listener = this.safe(listener)
|
||||
}
|
||||
|
||||
const createListener = () =>
|
||||
target ? createEventListener(target, type, listener, useCapture) : null
|
||||
target
|
||||
? createEventListener(target, type, listener, useCapture, this.options.angularMode)
|
||||
: null
|
||||
const deleteListener = () =>
|
||||
target ? deleteEventListener(target, type, listener, useCapture) : null
|
||||
target
|
||||
? deleteEventListener(target, type, listener, useCapture, this.options.angularMode)
|
||||
: null
|
||||
|
||||
this.attachStartCallback(createListener, useSafe)
|
||||
this.attachStopCallback(deleteListener, useSafe)
|
||||
|
|
@ -963,8 +1098,8 @@ export default class App {
|
|||
deviceMemory,
|
||||
jsHeapSizeLimit,
|
||||
timezone: getTimezone(),
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
width: window.screen.width,
|
||||
height: window.screen.height,
|
||||
}),
|
||||
})
|
||||
const {
|
||||
|
|
@ -978,7 +1113,9 @@ export default class App {
|
|||
userOS,
|
||||
userState,
|
||||
projectID,
|
||||
features,
|
||||
} = await r.json()
|
||||
this.features = features ? features : this.features
|
||||
this.session.assign({ projectID })
|
||||
this.session.setUserInfo({
|
||||
userBrowser,
|
||||
|
|
@ -991,9 +1128,11 @@ export default class App {
|
|||
const onStartInfo = { sessionToken: token, userUUID: '', sessionID: '' }
|
||||
this.startCallbacks.forEach((cb) => cb(onStartInfo))
|
||||
await this.conditionsManager?.fetchConditions(projectID as string, token as string)
|
||||
await this.featureFlags.reloadFlags(token as string)
|
||||
if (this.features['feature-flags']) {
|
||||
await this.featureFlags.reloadFlags(token as string)
|
||||
this.conditionsManager?.processFlags(this.featureFlags.flags)
|
||||
}
|
||||
await this.tagWatcher.fetchTags(this.options.ingestPoint, token as string)
|
||||
this.conditionsManager?.processFlags(this.featureFlags.flags)
|
||||
}
|
||||
|
||||
onSessionSent = () => {
|
||||
|
|
@ -1149,7 +1288,7 @@ export default class App {
|
|||
if (isColdStart && this.coldInterval) {
|
||||
clearInterval(this.coldInterval)
|
||||
}
|
||||
if (!this.worker) {
|
||||
if (!this.worker && !this.insideIframe) {
|
||||
const reason = 'No worker found: perhaps, CSP is not set.'
|
||||
this.signalError(reason, [])
|
||||
return Promise.resolve(UnsuccessfulStart(reason))
|
||||
|
|
@ -1181,7 +1320,7 @@ export default class App {
|
|||
})
|
||||
|
||||
const timestamp = now()
|
||||
this.worker.postMessage({
|
||||
this.worker?.postMessage({
|
||||
type: 'start',
|
||||
pageNo: this.session.incPageNo(),
|
||||
ingestPoint: this.options.ingestPoint,
|
||||
|
|
@ -1220,17 +1359,19 @@ export default class App {
|
|||
timezone: getTimezone(),
|
||||
condition: conditionName,
|
||||
assistOnly: startOpts.assistOnly ?? this.socketMode,
|
||||
width: window.screen.width,
|
||||
height: window.screen.height,
|
||||
}),
|
||||
})
|
||||
if (r.status !== 200) {
|
||||
const error = await r.text()
|
||||
const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}`
|
||||
return Promise.reject(reason)
|
||||
return UnsuccessfulStart(reason)
|
||||
}
|
||||
if (!this.worker) {
|
||||
const reason = 'no worker found after start request (this might not happen)'
|
||||
if (!this.worker && !this.insideIframe) {
|
||||
const reason = 'no worker found after start request (this should not happen in real world)'
|
||||
this.signalError(reason, [])
|
||||
return Promise.reject(reason)
|
||||
return UnsuccessfulStart(reason)
|
||||
}
|
||||
const {
|
||||
token,
|
||||
|
|
@ -1251,8 +1392,9 @@ export default class App {
|
|||
canvasQuality,
|
||||
canvasFPS,
|
||||
assistOnly: socketOnly,
|
||||
features,
|
||||
} = await r.json()
|
||||
|
||||
this.features = features ? features : this.features
|
||||
if (
|
||||
typeof token !== 'string' ||
|
||||
typeof userUUID !== 'string' ||
|
||||
|
|
@ -1263,7 +1405,7 @@ export default class App {
|
|||
) {
|
||||
const reason = `Incorrect server response: ${JSON.stringify(r)}`
|
||||
this.signalError(reason, [])
|
||||
return Promise.reject(reason)
|
||||
return UnsuccessfulStart(reason)
|
||||
}
|
||||
|
||||
this.delay = delay
|
||||
|
|
@ -1284,9 +1426,9 @@ export default class App {
|
|||
|
||||
if (socketOnly) {
|
||||
this.socketMode = true
|
||||
this.worker.postMessage('stop')
|
||||
this.worker?.postMessage('stop')
|
||||
} else {
|
||||
this.worker.postMessage({
|
||||
this.worker?.postMessage({
|
||||
type: 'auth',
|
||||
token,
|
||||
beaconSizeLimit,
|
||||
|
|
@ -1309,9 +1451,17 @@ export default class App {
|
|||
// TODO: start as early as possible (before receiving the token)
|
||||
/** after start */
|
||||
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
|
||||
void this.featureFlags.reloadFlags()
|
||||
if (startOpts.startCallback) {
|
||||
startOpts.startCallback(SuccessfulStart(onStartInfo))
|
||||
}
|
||||
if (this.features['feature-flags']) {
|
||||
void this.featureFlags.reloadFlags()
|
||||
}
|
||||
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
|
||||
this.activityState = ActivityState.Active
|
||||
if (this.options.crossdomain?.enabled && !this.insideIframe) {
|
||||
void this.bootChildrenFrames()
|
||||
}
|
||||
|
||||
if (canvasEnabled && !this.options.canvas.disableCanvas) {
|
||||
this.canvasRecorder =
|
||||
|
|
@ -1323,7 +1473,6 @@ export default class App {
|
|||
fixedScaling: this.options.canvas.fixedCanvasScaling,
|
||||
useAnimationFrame: this.options.canvas.useAnimationFrame,
|
||||
})
|
||||
this.canvasRecorder.startTracking()
|
||||
}
|
||||
|
||||
/** --------------- COLD START BUFFER ------------------*/
|
||||
|
|
@ -1346,32 +1495,37 @@ export default class App {
|
|||
}
|
||||
this.ticker.start()
|
||||
}
|
||||
this.canvasRecorder?.startTracking()
|
||||
|
||||
this.uxtManager = this.uxtManager ? this.uxtManager : new UserTestManager(this, uxtStorageKey)
|
||||
let uxtId: number | undefined
|
||||
const savedUxtTag = this.localStorage.getItem(uxtStorageKey)
|
||||
if (savedUxtTag) {
|
||||
uxtId = parseInt(savedUxtTag, 10)
|
||||
}
|
||||
if (location?.search) {
|
||||
const query = new URLSearchParams(location.search)
|
||||
if (query.has('oruxt')) {
|
||||
const qId = query.get('oruxt')
|
||||
uxtId = qId ? parseInt(qId, 10) : undefined
|
||||
if (this.features['usability-test']) {
|
||||
this.uxtManager = this.uxtManager
|
||||
? this.uxtManager
|
||||
: new UserTestManager(this, uxtStorageKey)
|
||||
let uxtId: number | undefined
|
||||
const savedUxtTag = this.localStorage.getItem(uxtStorageKey)
|
||||
if (savedUxtTag) {
|
||||
uxtId = parseInt(savedUxtTag, 10)
|
||||
}
|
||||
if (location?.search) {
|
||||
const query = new URLSearchParams(location.search)
|
||||
if (query.has('oruxt')) {
|
||||
const qId = query.get('oruxt')
|
||||
uxtId = qId ? parseInt(qId, 10) : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uxtId) {
|
||||
if (!this.uxtManager.isActive) {
|
||||
// eslint-disable-next-line
|
||||
this.uxtManager.getTest(uxtId, token, Boolean(savedUxtTag)).then((id) => {
|
||||
if (id) {
|
||||
this.onUxtCb.forEach((cb: (id: number) => void) => cb(id))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this.onUxtCb.forEach((cb: (id: number) => void) => cb(uxtId))
|
||||
if (uxtId) {
|
||||
if (!this.uxtManager.isActive) {
|
||||
// eslint-disable-next-line
|
||||
this.uxtManager.getTest(uxtId, token, Boolean(savedUxtTag)).then((id) => {
|
||||
if (id) {
|
||||
this.onUxtCb.forEach((cb: (id: number) => void) => cb(id))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this.onUxtCb.forEach((cb: (id: number) => void) => cb(uxtId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1379,6 +1533,11 @@ export default class App {
|
|||
} catch (reason) {
|
||||
this.stop()
|
||||
this.session.reset()
|
||||
if (!reason) {
|
||||
console.error('Unknown error during start')
|
||||
this.signalError('Unknown error', [])
|
||||
return UnsuccessfulStart('Unknown error')
|
||||
}
|
||||
if (reason === CANCELED) {
|
||||
this.signalError(CANCELED, [])
|
||||
return UnsuccessfulStart(CANCELED)
|
||||
|
|
@ -1437,9 +1596,13 @@ export default class App {
|
|||
}
|
||||
|
||||
async waitStarted() {
|
||||
return this.waitStatus(ActivityState.Active)
|
||||
}
|
||||
|
||||
async waitStatus(status: ActivityState) {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (this.activityState === ActivityState.Active) {
|
||||
if (this.activityState === status) {
|
||||
resolve(true)
|
||||
} else {
|
||||
setTimeout(check, 25)
|
||||
|
|
@ -1463,6 +1626,10 @@ export default class App {
|
|||
return Promise.resolve(UnsuccessfulStart(reason))
|
||||
}
|
||||
|
||||
if (this.insideIframe) {
|
||||
this.signalIframeTracker()
|
||||
}
|
||||
|
||||
if (!document.hidden) {
|
||||
await this.waitStart()
|
||||
return this._start(...args)
|
||||
|
|
@ -1518,20 +1685,28 @@ export default class App {
|
|||
stop(stopWorker = true): void {
|
||||
if (this.activityState !== ActivityState.NotActive) {
|
||||
try {
|
||||
if (!this.insideIframe && this.options.crossdomain?.enabled) {
|
||||
this.killChildrenFrames()
|
||||
}
|
||||
this.attributeSender.clear()
|
||||
this.sanitizer.clear()
|
||||
this.observer.disconnect()
|
||||
this.nodes.clear()
|
||||
this.ticker.stop()
|
||||
this.stopCallbacks.forEach((cb) => cb())
|
||||
this.debug.log('OpenReplay tracking stopped.')
|
||||
this.tagWatcher.clear()
|
||||
if (this.worker && stopWorker) {
|
||||
this.worker.postMessage('stop')
|
||||
}
|
||||
this.canvasRecorder?.clear()
|
||||
this.messages.length = 0
|
||||
this.trackedFrames = []
|
||||
this.parentActive = false
|
||||
this.canStart = false
|
||||
this.pollingQueue = { order: [] }
|
||||
} finally {
|
||||
this.activityState = ActivityState.NotActive
|
||||
this.debug.log('OpenReplay tracking stopped.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ export default class Nodes {
|
|||
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
|
||||
private nextNodeId = 0
|
||||
|
||||
constructor(private readonly node_id: string) {}
|
||||
constructor(
|
||||
private readonly node_id: string,
|
||||
private readonly angularMode: boolean,
|
||||
) {}
|
||||
|
||||
syntheticMode(frameOrder: number) {
|
||||
const maxSafeNumber = 9007199254740900
|
||||
const maxSafeNumber = Number.MAX_SAFE_INTEGER
|
||||
const placeholderSize = 99999999
|
||||
const nextFrameId = placeholderSize * frameOrder
|
||||
// I highly doubt that this will ever happen,
|
||||
|
|
@ -25,7 +28,7 @@ export default class Nodes {
|
|||
}
|
||||
|
||||
// Attached once per Tracker instance
|
||||
attachNodeCallback(nodeCallback: NodeCallback): void {
|
||||
attachNodeCallback = (nodeCallback: NodeCallback): void => {
|
||||
this.nodeCallbacks.push(nodeCallback)
|
||||
}
|
||||
|
||||
|
|
@ -33,12 +36,12 @@ export default class Nodes {
|
|||
this.nodes.forEach((node) => cb(node))
|
||||
}
|
||||
|
||||
attachNodeListener(node: Node, type: string, listener: EventListener, useCapture = true): void {
|
||||
attachNodeListener = (node: Node, type: string, listener: EventListener, useCapture = true): void => {
|
||||
const id = this.getID(node)
|
||||
if (id === undefined) {
|
||||
return
|
||||
}
|
||||
createEventListener(node, type, listener, useCapture)
|
||||
createEventListener(node, type, listener, useCapture, this.angularMode)
|
||||
let listeners = this.elementListeners.get(id)
|
||||
if (listeners === undefined) {
|
||||
listeners = []
|
||||
|
|
@ -70,7 +73,7 @@ export default class Nodes {
|
|||
if (listeners !== undefined) {
|
||||
this.elementListeners.delete(id)
|
||||
listeners.forEach((listener) =>
|
||||
deleteEventListener(node, listener[0], listener[1], listener[2]),
|
||||
deleteEventListener(node, listener[0], listener[1], listener[2], this.angularMode),
|
||||
)
|
||||
}
|
||||
this.totalNodeAmount--
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ export default class IFrameObserver extends Observer {
|
|||
})
|
||||
}
|
||||
|
||||
syntheticObserve(selfId: number, doc: Document) {
|
||||
syntheticObserve(rootNodeId: number, doc: Document) {
|
||||
this.observeRoot(doc, (docID) => {
|
||||
if (docID === undefined) {
|
||||
this.app.debug.log('OpenReplay: Iframe document not bound')
|
||||
return
|
||||
}
|
||||
this.app.send(CreateIFrameDocument(selfId, docID))
|
||||
this.app.send(CreateIFrameDocument(rootNodeId, docID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMutationObserver, ngSafeBrowserMethod } from '../../utils.js'
|
||||
import { createMutationObserver } from '../../utils.js'
|
||||
import {
|
||||
RemoveNodeAttribute,
|
||||
SetNodeAttributeURLBased,
|
||||
|
|
@ -105,6 +105,9 @@ export default abstract class Observer {
|
|||
if (name === null) {
|
||||
continue
|
||||
}
|
||||
if (target instanceof HTMLIFrameElement && name === 'src') {
|
||||
this.handleIframeSrcChange(target)
|
||||
}
|
||||
let attr = this.attributesMap.get(id)
|
||||
if (attr === undefined) {
|
||||
this.attributesMap.set(id, (attr = new Set()))
|
||||
|
|
@ -119,6 +122,7 @@ export default abstract class Observer {
|
|||
}
|
||||
this.commitNodes()
|
||||
}) as MutationCallback,
|
||||
this.app.options.angularMode,
|
||||
)
|
||||
}
|
||||
private clear(): void {
|
||||
|
|
@ -129,10 +133,49 @@ export default abstract class Observer {
|
|||
this.textSet.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbinds the removed nodes in case of iframe src change.
|
||||
*/
|
||||
private handleIframeSrcChange(iframe: HTMLIFrameElement): void {
|
||||
const oldContentDocument = iframe.contentDocument
|
||||
if (oldContentDocument) {
|
||||
const id = this.app.nodes.getID(oldContentDocument)
|
||||
if (id !== undefined) {
|
||||
const walker = document.createTreeWalker(
|
||||
oldContentDocument,
|
||||
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) =>
|
||||
isIgnored(node) || this.app.nodes.getID(node) === undefined
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT,
|
||||
},
|
||||
// @ts-ignore
|
||||
false,
|
||||
)
|
||||
|
||||
let removed = 0
|
||||
const totalBeforeRemove = this.app.nodes.getNodeCount()
|
||||
|
||||
while (walker.nextNode()) {
|
||||
if (!iframe.contentDocument.contains(walker.currentNode)) {
|
||||
removed += 1
|
||||
this.app.nodes.unregisterNode(walker.currentNode)
|
||||
}
|
||||
}
|
||||
|
||||
const removedPercent = Math.floor((removed / totalBeforeRemove) * 100)
|
||||
if (removedPercent > 30) {
|
||||
this.app.send(UnbindNodes(removedPercent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
|
||||
if (isSVGElement(node)) {
|
||||
if (name.substr(0, 6) === 'xlink:') {
|
||||
name = name.substr(6)
|
||||
if (name.substring(0, 6) === 'xlink:') {
|
||||
name = name.substring(6)
|
||||
}
|
||||
if (value === null) {
|
||||
this.app.send(RemoveNodeAttribute(id, name))
|
||||
|
|
@ -152,7 +195,7 @@ export default abstract class Observer {
|
|||
name === 'integrity' ||
|
||||
name === 'crossorigin' ||
|
||||
name === 'autocomplete' ||
|
||||
name.substr(0, 2) === 'on'
|
||||
name.substring(0, 2) === 'on'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export default class TopObserver extends Observer {
|
|||
)
|
||||
}
|
||||
|
||||
crossdomainObserve(selfId: number, frameOder: number) {
|
||||
crossdomainObserve(rootNodeId: number, frameOder: number) {
|
||||
const observer = this
|
||||
Element.prototype.attachShadow = function () {
|
||||
// eslint-disable-next-line
|
||||
|
|
@ -152,7 +152,7 @@ export default class TopObserver extends Observer {
|
|||
this.app.nodes.syntheticMode(frameOder)
|
||||
const iframeObserver = new IFrameObserver(this.app)
|
||||
this.iframeObservers.push(iframeObserver)
|
||||
iframeObserver.syntheticObserve(selfId, window.document)
|
||||
iframeObserver.syntheticObserve(rootNodeId, window.document)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export default function (app: App): void {
|
|||
}
|
||||
}
|
||||
}) as MutationCallback,
|
||||
app.options.angularMode,
|
||||
)
|
||||
|
||||
app.attachStopCallback(() => {
|
||||
|
|
|
|||
|
|
@ -132,9 +132,13 @@ export function ngSafeBrowserMethod(method: string): string {
|
|||
: method
|
||||
}
|
||||
|
||||
export function createMutationObserver(cb: MutationCallback) {
|
||||
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
|
||||
return new window[mObserver](cb)
|
||||
export function createMutationObserver(cb: MutationCallback, angularMode?: boolean) {
|
||||
if (angularMode) {
|
||||
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
|
||||
return new window[mObserver](cb)
|
||||
} else {
|
||||
return new MutationObserver(cb)
|
||||
}
|
||||
}
|
||||
|
||||
export function createEventListener(
|
||||
|
|
@ -142,15 +146,23 @@ export function createEventListener(
|
|||
event: string,
|
||||
cb: EventListenerOrEventListenerObject,
|
||||
capture?: boolean,
|
||||
angularMode?: boolean,
|
||||
) {
|
||||
const safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
|
||||
let safeAddEventListener: 'addEventListener'
|
||||
if (angularMode) {
|
||||
safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
|
||||
} else {
|
||||
safeAddEventListener = 'addEventListener'
|
||||
}
|
||||
try {
|
||||
target[safeAddEventListener](event, cb, capture)
|
||||
} catch (e) {
|
||||
const msg = e.message
|
||||
console.debug(
|
||||
console.error(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
|
||||
event,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -160,17 +172,23 @@ export function deleteEventListener(
|
|||
event: string,
|
||||
cb: EventListenerOrEventListenerObject,
|
||||
capture?: boolean,
|
||||
angularMode?: boolean,
|
||||
) {
|
||||
const safeRemoveEventListener = ngSafeBrowserMethod(
|
||||
'removeEventListener',
|
||||
) as 'removeEventListener'
|
||||
let safeRemoveEventListener: 'removeEventListener'
|
||||
if (angularMode) {
|
||||
safeRemoveEventListener = ngSafeBrowserMethod('removeEventListener') as 'removeEventListener'
|
||||
} else {
|
||||
safeRemoveEventListener = 'removeEventListener'
|
||||
}
|
||||
try {
|
||||
target[safeRemoveEventListener](event, cb, capture)
|
||||
} catch (e) {
|
||||
const msg = e.message
|
||||
console.debug(
|
||||
console.error(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
|
||||
event,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ describe('Nodes', () => {
|
|||
const mockCallback = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
nodes = new Nodes(nodeId)
|
||||
nodes = new Nodes(nodeId, false)
|
||||
mockCallback.mockClear()
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue