Merge pull request #360 from openreplay/dev

Chore(release): v1.5.3
This commit is contained in:
Mehdi Osman 2022-03-07 21:02:24 +01:00 committed by GitHub
commit fad85d0997
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
139 changed files with 12751 additions and 2690 deletions

View file

@ -1,4 +1,4 @@
FROM python:3.6-slim
FROM python:3.9.7-slim
LABEL Maintainer="Rajesh Rajendran<rjshrjndrn@gmail.com>"
WORKDIR /work
COPY . .

View file

@ -119,12 +119,6 @@ def Build(a):
q = f"""SELECT coalesce(value,0) AS value, coalesce(value,0) {a["query"]["operator"]} {a["query"]["right"]} AS valid"""
# if len(colDef.group) > 0 {
# subQ = subQ.Column(colDef.group + " AS group_value")
# subQ = subQ.GroupBy(colDef.group)
# q = q.Column("group_value")
# }
if a["detectionMethod"] == schemas.AlertDetectionMethod.threshold:
if a["seriesId"] is not None:
q += f""" FROM ({subQ}) AS stat"""
@ -134,16 +128,6 @@ def Build(a):
params = {**params, **full_args, "startDate": TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000}
else:
if a["options"]["change"] == schemas.AlertDetectionChangeType.change:
# if len(colDef.group) > 0:
# subq1 := subQ.Where(sq.Expr("timestamp>=$2 ", time.Now().Unix()-a.Options.CurrentPeriod * 60))
# sub2, args2, _ := subQ.Where(
# sq.And{
# sq.Expr("timestamp<$3 ", time.Now().Unix()-a.Options.CurrentPeriod * 60),
# sq.Expr("timestamp>=$4 ", time.Now().Unix()-2 * a.Options.CurrentPeriod * 60),
# }).ToSql()
# sub1 := sq.Select("group_value", "(stat1.value-stat2.value) AS value").FromSelect(subq1, "stat1").JoinClause("INNER JOIN ("+sub2+") AS stat2 USING(group_value)", args2...)
# q = q.FromSelect(sub1, "stat")
# else:
if a["seriesId"] is not None:
sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s")
sub1 = f"SELECT (({subQ})-({sub2})) AS value"
@ -163,16 +147,6 @@ def Build(a):
q += f" FROM ( {sub1} ) AS stat"
else:
# if len(colDef.group) >0 {
# subq1 := subQ.Where(sq.Expr("timestamp>=$2 ", time.Now().Unix()-a.Options.CurrentPeriod * 60))
# sub2, args2, _ := subQ.Where(
# sq.And{
# sq.Expr("timestamp<$3 ", time.Now().Unix()-a.Options.CurrentPeriod * 60),
# sq.Expr("timestamp>=$4 ", time.Now().Unix()-a.Options.PreviousPeriod * 60-a.Options.CurrentPeriod * 60),
# }).ToSql()
# sub1 := sq.Select("group_value", "(stat1.value/stat2.value-1)*100 AS value").FromSelect(subq1, "stat1").JoinClause("INNER JOIN ("+sub2+") AS stat2 USING(group_value)", args2...)
# q = q.FromSelect(sub1, "stat")
# } else {
if a["seriesId"] is not None:
sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s")
sub1 = f"SELECT (({subQ})/NULLIF(({sub2}),0)-1)*100 AS value"

View file

@ -1,35 +1,50 @@
import json
from typing import Union
import schemas
from chalicelib.core import sessions
from chalicelib.utils import helper, pg_client
from chalicelib.utils.TimeUTC import TimeUTC
PIE_CHART_GROUP = 5
def try_live(project_id, data: schemas.TryCustomMetricsSchema):
def __try_live(project_id, data: schemas.CreateCustomMetricsSchema):
results = []
for i, s in enumerate(data.series):
s.filter.startDate = data.startDate
s.filter.endDate = data.endDate
results.append(sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
view_type=data.viewType))
if data.viewType == schemas.MetricViewType.progress:
view_type=data.view_type, metric_type=data.metric_type,
metric_of=data.metric_of, metric_value=data.metric_value))
if data.view_type == schemas.MetricTimeseriesViewType.progress:
r = {"count": results[-1]}
diff = s.filter.endDate - s.filter.startDate
s.filter.startDate = data.endDate
s.filter.endDate = data.endDate - diff
r["previousCount"] = sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
view_type=data.viewType)
view_type=data.view_type, metric_type=data.metric_type,
metric_of=data.metric_of, metric_value=data.metric_value)
r["countProgress"] = helper.__progress(old_val=r["previousCount"], new_val=r["count"])
# r["countProgress"] = ((r["count"] - r["previousCount"]) / r["previousCount"]) * 100 \
# if r["previousCount"] > 0 else 0
r["seriesName"] = s.name if s.name else i + 1
r["seriesId"] = s.series_id if s.series_id else None
results[-1] = r
elif data.view_type == schemas.MetricTableViewType.pie_chart:
if len(results[i].get("values", [])) > PIE_CHART_GROUP:
results[i]["values"] = results[i]["values"][:PIE_CHART_GROUP] \
+ [{
"name": "Others", "group": True,
"sessionCount": sum(r["sessionCount"] for r in results[i]["values"][PIE_CHART_GROUP:])
}]
return results
def merged_live(project_id, data: schemas.TryCustomMetricsSchema):
series_charts = try_live(project_id=project_id, data=data)
if data.viewType == schemas.MetricViewType.progress:
def merged_live(project_id, data: schemas.CreateCustomMetricsSchema):
series_charts = __try_live(project_id=project_id, data=data)
if data.view_type == schemas.MetricTimeseriesViewType.progress or data.metric_type == schemas.MetricType.table:
return series_charts
results = [{}] * len(series_charts[0])
for i in range(len(results)):
@ -39,13 +54,30 @@ def merged_live(project_id, data: schemas.TryCustomMetricsSchema):
return results
def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPayloadSchema):
def __get_merged_metric(project_id, user_id, metric_id,
data: Union[schemas.CustomMetricChartPayloadSchema,
schemas.CustomMetricSessionsPayloadSchema]) \
-> Union[schemas.CreateCustomMetricsSchema, None]:
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
metric: schemas.TryCustomMetricsSchema = schemas.TryCustomMetricsSchema.parse_obj({**data.dict(), **metric})
series_charts = try_live(project_id=project_id, data=metric)
if data.viewType == schemas.MetricViewType.progress:
metric: schemas.CreateCustomMetricsSchema = schemas.CreateCustomMetricsSchema.parse_obj({**data.dict(), **metric})
if len(data.filters) > 0 or len(data.events) > 0:
for s in metric.series:
if len(data.filters) > 0:
s.filter.filters += data.filters
if len(data.events) > 0:
s.filter.events += data.events
return metric
def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPayloadSchema):
metric: schemas.CreateCustomMetricsSchema = __get_merged_metric(project_id=project_id, user_id=user_id,
metric_id=metric_id, data=data)
if metric is None:
return None
series_charts = __try_live(project_id=project_id, data=metric)
if metric.view_type == schemas.MetricTimeseriesViewType.progress or metric.metric_type == schemas.MetricType.table:
return series_charts
results = [{}] * len(series_charts[0])
for i in range(len(results)):
@ -55,11 +87,11 @@ def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPa
return results
def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricRawPayloadSchema):
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricSessionsPayloadSchema):
metric: schemas.CreateCustomMetricsSchema = __get_merged_metric(project_id=project_id, user_id=user_id,
metric_id=metric_id, data=data)
if metric is None:
return None
metric: schemas.TryCustomMetricsSchema = schemas.TryCustomMetricsSchema.parse_obj({**data.dict(), **metric})
results = []
for s in metric.series:
s.filter.startDate = data.startDate
@ -82,8 +114,10 @@ def create(project_id, user_id, data: schemas.CreateCustomMetricsSchema):
data.series = None
params = {"user_id": user_id, "project_id": project_id, **data.dict(), **_data}
query = cur.mogrify(f"""\
WITH m AS (INSERT INTO metrics (project_id, user_id, name)
VALUES (%(project_id)s, %(user_id)s, %(name)s)
WITH m AS (INSERT INTO metrics (project_id, user_id, name, is_public,
view_type, metric_type, metric_of, metric_value, metric_format)
VALUES (%(project_id)s, %(user_id)s, %(name)s, %(is_public)s,
%(view_type)s, %(metric_type)s, %(metric_of)s, %(metric_value)s, %(metric_format)s)
RETURNING *)
INSERT
INTO metric_series(metric_id, index, name, filter)
@ -98,32 +132,22 @@ def create(project_id, user_id, data: schemas.CreateCustomMetricsSchema):
return {"data": get(metric_id=r["metric_id"], project_id=project_id, user_id=user_id)}
def __get_series_id(metric_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
"""SELECT series_id
FROM metric_series
WHERE metric_series.metric_id = %(metric_id)s
AND metric_series.deleted_at ISNULL;""",
{"metric_id": metric_id}
)
)
rows = cur.fetchall()
return [r["series_id"] for r in rows]
def update(metric_id, user_id, project_id, data: schemas.UpdateCustomMetricsSchema):
series_ids = __get_series_id(metric_id)
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
series_ids = [r["seriesId"] for r in metric["series"]]
n_series = []
d_series_ids = []
u_series = []
u_series_ids = []
params = {"metric_id": metric_id, "is_public": data.is_public, "name": data.name,
"user_id": user_id, "project_id": project_id}
"user_id": user_id, "project_id": project_id, "view_type": data.view_type,
"metric_type": data.metric_type, "metric_of": data.metric_of,
"metric_value": data.metric_value, "metric_format": data.metric_format}
for i, s in enumerate(data.series):
prefix = "u_"
if s.series_id is None:
if s.series_id is None or s.series_id not in series_ids:
n_series.append({"i": i, "s": s})
prefix = "n_"
s.index = i
@ -165,7 +189,10 @@ def update(metric_id, user_id, project_id, data: schemas.UpdateCustomMetricsSche
query = cur.mogrify(f"""\
{"WITH " if len(sub_queries) > 0 else ""}{",".join(sub_queries)}
UPDATE metrics
SET name = %(name)s, is_public= %(is_public)s
SET name = %(name)s, is_public= %(is_public)s,
view_type= %(view_type)s, metric_type= %(metric_type)s,
metric_of= %(metric_of)s, metric_value= %(metric_value)s,
metric_format= %(metric_format)s
WHERE metric_id = %(metric_id)s
AND project_id = %(project_id)s
AND (user_id = %(user_id)s OR is_public)
@ -224,7 +251,7 @@ def get(metric_id, project_id, user_id, flatten=True):
cur.mogrify(
"""SELECT *
FROM metrics
LEFT JOIN LATERAL (SELECT jsonb_agg(metric_series.* ORDER BY index) AS series
LEFT JOIN LATERAL (SELECT COALESCE(jsonb_agg(metric_series.* ORDER BY index),'[]'::jsonb) AS series
FROM metric_series
WHERE metric_series.metric_id = metrics.metric_id
AND metric_series.deleted_at ISNULL
@ -261,6 +288,7 @@ def get_series_for_alert(project_id, user_id):
INNER JOIN metrics USING (metric_id)
WHERE metrics.deleted_at ISNULL
AND metrics.project_id = %(project_id)s
AND metrics.metric_type = 'timeseries'
AND (user_id = %(user_id)s OR is_public)
ORDER BY name;""",
{"project_id": project_id, "user_id": user_id}

View file

@ -69,20 +69,25 @@ def get_projects(tenant_id, recording_state=False, gdpr=None, recorded=False, st
)
rows = cur.fetchall()
if recording_state:
project_ids = [f'({r["project_id"]})' for r in rows]
query = f"""SELECT projects.project_id, COALESCE(MAX(start_ts), 0) AS last
FROM (VALUES {",".join(project_ids)}) AS projects(project_id)
LEFT JOIN sessions USING (project_id)
GROUP BY project_id;"""
cur.execute(
query=query
)
status = cur.fetchall()
for r in rows:
query = cur.mogrify(
"select COALESCE(MAX(start_ts),0) AS last from public.sessions where project_id=%(project_id)s;",
{"project_id": r["project_id"]})
cur.execute(
query=query
)
status = cur.fetchone()
if status["last"] < TimeUTC.now(-2):
r["status"] = "red"
elif status["last"] < TimeUTC.now(-1):
r["status"] = "yellow"
else:
r["status"] = "green"
for s in status:
if s["project_id"] == r["project_id"]:
if s["last"] < TimeUTC.now(-2):
r["status"] = "red"
elif s["last"] < TimeUTC.now(-1):
r["status"] = "yellow"
else:
r["status"] = "green"
break
return helper.list_to_camel_case(rows)

View file

@ -1,3 +1,5 @@
from typing import List
import schemas
from chalicelib.core import events, metadata, events_ios, \
sessions_mobs, issues, projects, errors, resources, assist, performance_event
@ -198,7 +200,7 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
ROW_NUMBER() OVER (ORDER BY count(full_sessions) DESC) AS rn
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY start_ts DESC) AS rn
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
{"," if len(meta_keys)>0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
{"," if len(meta_keys) > 0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions
@ -210,7 +212,7 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count, COALESCE(JSONB_AGG(full_sessions) FILTER (WHERE rn <= 200), '[]'::JSONB) AS sessions
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY favorite DESC, issue_score DESC, session_id desc, start_ts desc) AS rn
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
{"," if len(meta_keys)>0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
{"," if len(meta_keys) > 0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions;""",
@ -225,9 +227,9 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
# print("--------------------")
# print(main_query)
cur.execute(main_query)
# print("--------------------")
cur.execute(main_query)
if count_only:
return helper.dict_to_camel_case(cur.fetchone())
sessions = cur.fetchone()
@ -264,44 +266,103 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
}
@dev.timed
def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int,
view_type: schemas.MetricViewType):
view_type: schemas.MetricTimeseriesViewType, metric_type: schemas.MetricType,
metric_of: schemas.TableMetricOfType, metric_value: List):
step_size = int(metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
density=density, factor=1, decimal=True))
extra_event = None
if metric_of == schemas.TableMetricOfType.visited_url:
extra_event = "events.pages"
elif metric_of == schemas.TableMetricOfType.issues and len(metric_value) > 0:
data.filters.append(schemas.SessionSearchFilterSchema(value=metric_value, type=schemas.FilterType.issue,
operator=schemas.SearchEventOperator._is))
full_args, query_part, sort = search_query_parts(data=data, error_status=None, errors_only=False,
favorite_only=False, issue=None, project_id=project_id,
user_id=None)
user_id=None, extra_event=extra_event)
full_args["step_size"] = step_size
sessions = []
with pg_client.PostgresClient() as cur:
if view_type == schemas.MetricViewType.line_chart:
main_query = cur.mogrify(f"""WITH full_sessions AS (SELECT DISTINCT ON(s.session_id) s.session_id, s.start_ts
{query_part})
SELECT generated_timestamp AS timestamp,
COUNT(s) AS count
FROM generate_series(%(startDate)s, %(endDate)s, %(step_size)s) AS generated_timestamp
LEFT JOIN LATERAL ( SELECT 1 AS s
FROM full_sessions
WHERE start_ts >= generated_timestamp
AND start_ts <= generated_timestamp + %(step_size)s) AS sessions ON (TRUE)
GROUP BY generated_timestamp
ORDER BY generated_timestamp;""", full_args)
else:
main_query = cur.mogrify(f"""SELECT count(DISTINCT s.session_id) AS count
{query_part};""", full_args)
if metric_type == schemas.MetricType.timeseries:
if view_type == schemas.MetricTimeseriesViewType.line_chart:
main_query = cur.mogrify(f"""WITH full_sessions AS (SELECT DISTINCT ON(s.session_id) s.session_id, s.start_ts
{query_part})
SELECT generated_timestamp AS timestamp,
COUNT(s) AS count
FROM generate_series(%(startDate)s, %(endDate)s, %(step_size)s) AS generated_timestamp
LEFT JOIN LATERAL ( SELECT 1 AS s
FROM full_sessions
WHERE start_ts >= generated_timestamp
AND start_ts <= generated_timestamp + %(step_size)s) AS sessions ON (TRUE)
GROUP BY generated_timestamp
ORDER BY generated_timestamp;""", full_args)
else:
main_query = cur.mogrify(f"""SELECT count(DISTINCT s.session_id) AS count
{query_part};""", full_args)
# print("--------------------")
# print(main_query)
# print("--------------------")
cur.execute(main_query)
if view_type == schemas.MetricTimeseriesViewType.line_chart:
sessions = cur.fetchall()
else:
sessions = cur.fetchone()["count"]
elif metric_type == schemas.MetricType.table:
if isinstance(metric_of, schemas.TableMetricOfType):
main_col = "user_id"
extra_col = ""
extra_where = ""
pre_query = ""
if metric_of == schemas.TableMetricOfType.user_country:
main_col = "user_country"
elif metric_of == schemas.TableMetricOfType.user_device:
main_col = "user_device"
elif metric_of == schemas.TableMetricOfType.user_browser:
main_col = "user_browser"
elif metric_of == schemas.TableMetricOfType.issues:
main_col = "issue"
extra_col = f", UNNEST(s.issue_types) AS {main_col}"
if len(metric_value) > 0:
extra_where = []
for i in range(len(metric_value)):
arg_name = f"selected_issue_{i}"
extra_where.append(f"{main_col} = %({arg_name})s")
full_args[arg_name] = metric_value[i]
extra_where = f"WHERE ({' OR '.join(extra_where)})"
elif metric_of == schemas.TableMetricOfType.visited_url:
main_col = "base_path"
extra_col = ", base_path"
main_query = cur.mogrify(f"""{pre_query}
SELECT COUNT(*) AS count, COALESCE(JSONB_AGG(users_sessions) FILTER ( WHERE rn <= 200 ), '[]'::JSONB) AS values
FROM (SELECT {main_col} AS name,
count(full_sessions) AS session_count,
ROW_NUMBER() OVER (ORDER BY count(full_sessions) DESC) AS rn
FROM (SELECT *
FROM (SELECT DISTINCT ON(s.session_id) s.session_id, s.user_uuid,
s.user_id, s.user_os,
s.user_browser, s.user_device,
s.user_device_type, s.user_country, s.issue_types{extra_col}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
) AS full_sessions
{extra_where}
GROUP BY {main_col}
ORDER BY session_count DESC) AS users_sessions;""",
full_args)
# print("--------------------")
# print(main_query)
# print("--------------------")
cur.execute(main_query)
sessions = cur.fetchone()
for s in sessions["values"]:
s.pop("rn")
sessions["values"] = helper.list_to_camel_case(sessions["values"])
# print("--------------------")
# print(main_query)
cur.execute(main_query)
# print("--------------------")
if view_type == schemas.MetricViewType.line_chart:
sessions = cur.fetchall()
else:
sessions = cur.fetchone()["count"]
return sessions
def search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id, user_id):
def search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id, user_id, extra_event=None):
ss_constraints = []
full_args = {"project_id": project_id, "startDate": data.startDate, "endDate": data.endDate,
"projectId": project_id, "userId": user_id}
@ -521,7 +582,6 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
value_key=f_k))
# ---------------------------------------------------------------------------
if len(data.events) > 0:
# ss_constraints = [s.decode('UTF-8') for s in ss_constraints]
events_query_from = []
event_index = 0
or_events = data.events_order == schemas.SearchEventOrder._or
@ -532,13 +592,16 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
is_any = _isAny_opreator(event.operator)
if not isinstance(event.value, list):
event.value = [event.value]
if not is_any and len(event.value) == 0 \
if not is_any and len(event.value) == 0 and event_type not in [schemas.EventType.request_details,
schemas.EventType.graphql_details] \
or event_type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
schemas.PerformanceEventType.location_avg_cpu_load,
schemas.PerformanceEventType.location_avg_memory_usage
] and (event.source is None or len(event.source) == 0):
] and (event.source is None or len(event.source) == 0) \
or event_type in [schemas.EventType.request_details, schemas.EventType.graphql_details] and (
event.filters is None or len(event.filters) == 0):
continue
op = __get_sql_operator(event.operator)
is_not = False
@ -737,15 +800,19 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
event_where += ["main2.timestamp >= %(startDate)s", "main2.timestamp <= %(endDate)s"]
if event_index > 0 and not or_events:
event_where.append("main2.session_id=event_0.session_id")
event_where.append(
_multiple_conditions(
f"main.{getattr(events.event_type, event.value[0].type).column} {s_op} %({e_k1})s",
event.value[0].value, value_key=e_k1))
is_any = _isAny_opreator(event.value[0].operator)
if not is_any:
event_where.append(
_multiple_conditions(
f"main.{getattr(events.event_type, event.value[0].type).column} {s_op} %({e_k1})s",
event.value[0].value, value_key=e_k1))
s_op = __get_sql_operator(event.value[1].operator)
event_where.append(
_multiple_conditions(
f"main2.{getattr(events.event_type, event.value[1].type).column} {s_op} %({e_k2})s",
event.value[1].value, value_key=e_k2))
is_any = _isAny_opreator(event.value[1].operator)
if not is_any:
event_where.append(
_multiple_conditions(
f"main2.{getattr(events.event_type, event.value[1].type).column} {s_op} %({e_k2})s",
event.value[1].value, value_key=e_k2))
e_k += "_custom"
full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)}
@ -753,7 +820,66 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
_multiple_conditions(f"main2.timestamp - main.timestamp {event.sourceOperator} %({e_k})s",
event.source, value_key=e_k))
elif event_type == schemas.EventType.request_details:
event_from = event_from % f"{events.event_type.REQUEST.table} AS main "
for j, f in enumerate(event.filters):
is_any = _isAny_opreator(f.operator)
if is_any or len(f.value) == 0:
continue
op = __get_sql_operator(f.operator)
e_k_f = e_k + f"_fetch{j}"
full_args = {**full_args, **_multiple_values(f.value, value_key=e_k_f)}
if f.type == schemas.FetchFilterType._url:
event_where.append(
_multiple_conditions(f"main.{events.event_type.REQUEST.column} {op} %({e_k_f})s", f.value,
value_key=e_k_f))
elif f.type == schemas.FetchFilterType._status_code:
event_where.append(
_multiple_conditions(f"main.status_code {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.FetchFilterType._method:
event_where.append(
_multiple_conditions(f"main.method {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.FetchFilterType._duration:
event_where.append(
_multiple_conditions(f"main.duration {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.FetchFilterType._request_body:
event_where.append(
_multiple_conditions(f"main.request_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.FetchFilterType._response_body:
event_where.append(
_multiple_conditions(f"main.response_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
else:
print(f"undefined FETCH filter: {f.type}")
elif event_type == schemas.EventType.graphql_details:
event_from = event_from % f"{events.event_type.GRAPHQL.table} AS main "
for j, f in enumerate(event.filters):
is_any = _isAny_opreator(f.operator)
if is_any or len(f.value) == 0:
continue
op = __get_sql_operator(f.operator)
e_k_f = e_k + f"_graphql{j}"
full_args = {**full_args, **_multiple_values(f.value, value_key=e_k_f)}
if f.type == schemas.GraphqlFilterType._name:
event_where.append(
_multiple_conditions(f"main.{events.event_type.GRAPHQL.column} {op} %({e_k_f})s", f.value,
value_key=e_k_f))
elif f.type == schemas.GraphqlFilterType._status_code:
event_where.append(
_multiple_conditions(f"main.status_code {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.GraphqlFilterType._method:
event_where.append(
_multiple_conditions(f"main.method {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.GraphqlFilterType._duration:
event_where.append(
_multiple_conditions(f"main.duration {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.GraphqlFilterType._request_body:
event_where.append(
_multiple_conditions(f"main.request_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
elif f.type == schemas.GraphqlFilterType._response_body:
event_where.append(
_multiple_conditions(f"main.response_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
else:
print(f"undefined GRAPHQL filter: {f.type}")
else:
continue
if event_index == 0 or or_events:
@ -856,6 +982,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
"""
full_args["issue_contextString"] = issue["contextString"]
full_args["issue_type"] = issue["type"]
if extra_event:
extra_join += f"""INNER JOIN {extra_event} AS ev USING(session_id)"""
extra_constraints.append("ev.timestamp>=%(startDate)s")
extra_constraints.append("ev.timestamp<=%(endDate)s")
query_part = f"""\
FROM {f"({events_query_part}) AS f" if len(events_query_part) > 0 else "public.sessions AS s"}
{extra_join}

View file

@ -63,13 +63,12 @@ def create_step1(data: schemas.UserSignupSchema):
"fullname": fullname,
"projectName": project_name,
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
"organizationName": company_name,
"versionNumber": config("version_number")
"organizationName": company_name
}
query = f"""\
WITH t AS (
INSERT INTO public.tenants (name, version_number, edition)
VALUES (%(organizationName)s, %(versionNumber)s, 'fos')
VALUES (%(organizationName)s, (SELECT openreplay_version()), 'fos')
RETURNING api_key
),
u AS (

View file

@ -101,12 +101,15 @@ def comment_assignment(projectId: int, sessionId: int, issueId: str, data: schem
@app.get('/{projectId}/events/search', tags=["events"])
def events_search(projectId: int, q: str,
type: Union[schemas.FilterType, schemas.EventType, schemas.PerformanceEventType] = None,
type: Union[schemas.FilterType, schemas.EventType,
schemas.PerformanceEventType, schemas.FetchFilterType] = None,
key: str = None,
source: str = None, context: schemas.CurrentContext = Depends(OR_context)):
if len(q) == 0:
return {"data": []}
if isinstance(type, schemas.PerformanceEventType):
if type in [schemas.FetchFilterType._url]:
type = schemas.EventType.request
elif isinstance(type, schemas.PerformanceEventType):
if type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
@ -1088,25 +1091,9 @@ def change_client_password(data: schemas.EditUserPasswordSchema = Body(...),
@app.post('/{projectId}/custom_metrics/try', tags=["customMetrics"])
@app.put('/{projectId}/custom_metrics/try', tags=["customMetrics"])
def try_custom_metric(projectId: int, data: schemas.TryCustomMetricsSchema = Body(...),
def try_custom_metric(projectId: int, data: schemas.CreateCustomMetricsSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.merged_live
(project_id=projectId, data=data)}
@app.post('/{projectId}/custom_metrics/sessions', tags=["customMetrics"])
def get_custom_metric_sessions(projectId: int, data: schemas.CustomMetricRawPayloadSchema2 = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.get_sessions(project_id=projectId, user_id=context.user_id, metric_id=data.metric_id,
data=data)}
@app.post('/{projectId}/custom_metrics/chart', tags=["customMetrics"])
@app.put('/{projectId}/custom_metrics/chart', tags=["customMetrics"])
def get_custom_metric_chart(projectId: int, data: schemas.CustomMetricChartPayloadSchema2 = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.make_chart(project_id=projectId, user_id=context.user_id, metric_id=data.metric_id,
data=data)}
return {"data": custom_metrics.merged_live(project_id=projectId, data=data)}
@app.post('/{projectId}/custom_metrics', tags=["customMetrics"])
@ -1123,29 +1110,40 @@ def get_custom_metrics(projectId: int, context: schemas.CurrentContext = Depends
@app.get('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
def get_custom_metric(projectId: int, metric_id: int, context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.get(project_id=projectId, user_id=context.user_id, metric_id=metric_id)}
data = custom_metrics.get(project_id=projectId, user_id=context.user_id, metric_id=metric_id)
if data is None:
return {"errors": ["custom metric not found"]}
return {"data": data}
@app.post('/{projectId}/custom_metrics/{metric_id}/sessions', tags=["customMetrics"])
def get_custom_metric_sessions(projectId: int, metric_id: int, data: schemas.CustomMetricRawPayloadSchema = Body(...),
def get_custom_metric_sessions(projectId: int, metric_id: int,
data: schemas.CustomMetricSessionsPayloadSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.get_sessions(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
data=data)}
data = custom_metrics.get_sessions(project_id=projectId, user_id=context.user_id, metric_id=metric_id, data=data)
if data is None:
return {"errors": ["custom metric not found"]}
return {"data": data}
@app.post('/{projectId}/custom_metrics/{metric_id}/chart', tags=["customMetrics"])
def get_custom_metric_chart(projectId: int, metric_id: int, data: schemas.CustomMetricChartPayloadSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.make_chart(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
data=data)}
data = custom_metrics.make_chart(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
data=data)
if data is None:
return {"errors": ["custom metric not found"]}
return {"data": data}
@app.post('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
@app.put('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
def update_custom_metric(projectId: int, metric_id: int, data: schemas.UpdateCustomMetricsSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {
"data": custom_metrics.update(project_id=projectId, user_id=context.user_id, metric_id=metric_id, data=data)}
data = custom_metrics.update(project_id=projectId, user_id=context.user_id, metric_id=metric_id, data=data)
if data is None:
return {"errors": ["custom metric not found"]}
return {"data": data}
@app.post('/{projectId}/custom_metrics/{metric_id}/status', tags=["customMetrics"])

View file

@ -384,7 +384,9 @@ class EventType(str, Enum):
location = "LOCATION"
custom = "CUSTOM"
request = "REQUEST"
request_details = "FETCH"
graphql = "GRAPHQL"
graphql_details = "GRAPHQL_DETAILS"
state_action = "STATEACTION"
error = "ERROR"
click_ios = "CLICK_IOS"
@ -480,7 +482,8 @@ class __MixedSearchFilter(BaseModel):
@root_validator(pre=True)
def remove_duplicate_values(cls, values):
if values.get("value") is not None:
if len(values["value"]) > 0 and isinstance(values["value"][0], int):
if len(values["value"]) > 0 \
and (isinstance(values["value"][0], int) or isinstance(values["value"][0], dict)):
return values
values["value"] = list(set(values["value"]))
return values
@ -489,6 +492,42 @@ class __MixedSearchFilter(BaseModel):
alias_generator = attribute_to_camel_case
class HttpMethod(str, Enum):
_get = 'GET'
_head = 'HEAD'
_post = 'POST'
_put = 'PUT'
_delete = 'DELETE'
_connect = 'CONNECT'
_option = 'OPTIONS'
_trace = 'TRACE'
_patch = 'PATCH'
class FetchFilterType(str, Enum):
_url = "FETCH_URL"
_status_code = "FETCH_STATUS_CODE"
_method = "FETCH_METHOD"
_duration = "FETCH_DURATION"
_request_body = "FETCH_REQUEST_BODY"
_response_body = "FETCH_RESPONSE_BODY"
class GraphqlFilterType(str, Enum):
_name = "GRAPHQL_NAME"
_status_code = "GRAPHQL_STATUS_CODE"
_method = "GRAPHQL_METHOD"
_duration = "GRAPHQL_DURATION"
_request_body = "GRAPHQL_REQUEST_BODY"
_response_body = "GRAPHQL_RESPONSE_BODY"
class RequestGraphqlFilterSchema(BaseModel):
type: Union[FetchFilterType, GraphqlFilterType] = Field(...)
value: List[Union[int, str]] = Field(...)
operator: Union[SearchEventOperator, MathOperator] = Field(...)
class _SessionSearchEventRaw(__MixedSearchFilter):
is_event: bool = Field(default=True, const=True)
value: List[str] = Field(...)
@ -496,6 +535,7 @@ class _SessionSearchEventRaw(__MixedSearchFilter):
operator: SearchEventOperator = Field(...)
source: Optional[List[Union[ErrorSource, int, str]]] = Field(None)
sourceOperator: Optional[MathOperator] = Field(None)
filters: Optional[List[RequestGraphqlFilterSchema]] = Field(None)
@root_validator
def event_validator(cls, values):
@ -513,20 +553,28 @@ class _SessionSearchEventRaw(__MixedSearchFilter):
assert isinstance(values["value"][0], _SessionSearchEventRaw) \
and isinstance(values["value"][1], _SessionSearchEventRaw), \
f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}"
assert len(values["source"]) > 0 and isinstance(values["source"][0], int), \
f"source of type int if required for {PerformanceEventType.time_between_events}"
else:
for c in values["source"]:
assert isinstance(c, int), f"source value should be of type int for {values.get('type')}"
elif values.get("type") == EventType.error and values.get("source") is None:
values["source"] = [ErrorSource.js_exception]
elif values.get("type") == EventType.request_details:
assert isinstance(values.get("filters"), List) and len(values.get("filters", [])) > 0, \
f"filters should be defined for {EventType.request_details.value}"
elif values.get("type") == EventType.graphql_details:
assert isinstance(values.get("filters"), List) and len(values.get("filters", [])) > 0, \
f"filters should be defined for {EventType.graphql_details.value}"
return values
class _SessionSearchEventSchema(_SessionSearchEventRaw):
value: Union[List[_SessionSearchEventRaw], str, List[str]] = Field(...)
value: Union[List[Union[_SessionSearchEventRaw, str]], str] = Field(...)
class _SessionSearchFilterSchema(__MixedSearchFilter):
class SessionSearchFilterSchema(__MixedSearchFilter):
is_event: bool = Field(False, const=False)
value: Union[Optional[Union[IssueType, PlatformType, int, str]],
Optional[List[Union[IssueType, PlatformType, int, str]]]] = Field(...)
@ -559,7 +607,7 @@ class _SessionSearchFilterSchema(__MixedSearchFilter):
class SessionsSearchPayloadSchema(BaseModel):
events: List[_SessionSearchEventSchema] = Field([])
filters: List[_SessionSearchFilterSchema] = Field([])
filters: List[SessionSearchFilterSchema] = Field([])
startDate: int = Field(None)
endDate: int = Field(None)
sort: str = Field(default="startTs")
@ -571,9 +619,9 @@ class SessionsSearchPayloadSchema(BaseModel):
alias_generator = attribute_to_camel_case
class FlatSessionsSearchPayloadSchema(SessionsSearchPayloadSchema):
class FlatSessionsSearch(BaseModel):
events: Optional[List[_SessionSearchEventSchema]] = Field([])
filters: List[Union[_SessionSearchFilterSchema, _SessionSearchEventSchema]] = Field([])
filters: List[Union[SessionSearchFilterSchema, _SessionSearchEventSchema]] = Field([])
@root_validator(pre=True)
def flat_to_original(cls, values):
@ -597,6 +645,10 @@ class FlatSessionsSearchPayloadSchema(SessionsSearchPayloadSchema):
return values
class FlatSessionsSearchPayloadSchema(FlatSessionsSearch, SessionsSearchPayloadSchema):
pass
class SessionsSearchCountSchema(FlatSessionsSearchPayloadSchema):
# class SessionsSearchCountSchema(SessionsSearchPayloadSchema):
sort: Optional[str] = Field(default=None)
@ -688,21 +740,36 @@ class CustomMetricCreateSeriesSchema(BaseModel):
alias_generator = attribute_to_camel_case
class CreateCustomMetricsSchema(BaseModel):
name: str = Field(...)
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
is_public: Optional[bool] = Field(True)
class Config:
alias_generator = attribute_to_camel_case
class MetricViewType(str, Enum):
class MetricTimeseriesViewType(str, Enum):
line_chart = "lineChart"
progress = "progress"
class CustomMetricRawPayloadSchema(BaseModel):
class MetricTableViewType(str, Enum):
table = "table"
pie_chart = "pieChart"
class MetricType(str, Enum):
timeseries = "timeseries"
table = "table"
class TableMetricOfType(str, Enum):
user_os = FilterType.user_os.value
user_browser = FilterType.user_browser.value
user_device = FilterType.user_device.value
user_country = FilterType.user_country.value
user_id = FilterType.user_id.value
issues = FilterType.issue.value
visited_url = EventType.location.value
class TimeseriesMetricOfType(str, Enum):
session_count = "sessionCount"
class CustomMetricSessionsPayloadSchema(FlatSessionsSearch):
startDate: int = Field(TimeUTC.now(-7))
endDate: int = Field(TimeUTC.now())
@ -710,23 +777,52 @@ class CustomMetricRawPayloadSchema(BaseModel):
alias_generator = attribute_to_camel_case
class CustomMetricRawPayloadSchema2(CustomMetricRawPayloadSchema):
metric_id: int = Field(...)
class CustomMetricChartPayloadSchema(CustomMetricRawPayloadSchema):
startDate: int = Field(TimeUTC.now(-7))
endDate: int = Field(TimeUTC.now())
class CustomMetricChartPayloadSchema(CustomMetricSessionsPayloadSchema):
density: int = Field(7)
viewType: MetricViewType = Field(MetricViewType.line_chart)
class Config:
alias_generator = attribute_to_camel_case
class CustomMetricChartPayloadSchema2(CustomMetricChartPayloadSchema):
metric_id: int = Field(...)
class CreateCustomMetricsSchema(CustomMetricChartPayloadSchema):
name: str = Field(...)
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
is_public: bool = Field(default=True, const=True)
view_type: Union[MetricTimeseriesViewType, MetricTableViewType] = Field(MetricTimeseriesViewType.line_chart)
metric_type: MetricType = Field(MetricType.timeseries)
metric_of: Union[TableMetricOfType, TimeseriesMetricOfType] = Field(TableMetricOfType.user_id)
metric_value: List[IssueType] = Field([])
metric_format: Optional[str] = Field(None)
# metricFraction: float = Field(None, gt=0, lt=1)
# This is used to handle wrong values sent by the UI
@root_validator(pre=True)
def remove_metric_value(cls, values):
if values.get("metricType") == MetricType.timeseries \
or values.get("metricType") == MetricType.table \
and values.get("metricOf") != TableMetricOfType.issues:
values["metricValue"] = []
return values
class TryCustomMetricsSchema(CreateCustomMetricsSchema, CustomMetricChartPayloadSchema):
name: Optional[str] = Field(None)
@root_validator
def validator(cls, values):
if values.get("metric_type") == MetricType.table:
assert isinstance(values.get("view_type"), MetricTableViewType), \
f"viewType must be of type {MetricTableViewType} for metricType:{MetricType.table.value}"
assert isinstance(values.get("metric_of"), TableMetricOfType), \
f"metricOf must be of type {TableMetricOfType} for metricType:{MetricType.table.value}"
if values.get("metric_of") != TableMetricOfType.issues:
assert values.get("metric_value") is None or len(values.get("metric_value")) == 0, \
f"metricValue is only available for metricOf:{TableMetricOfType.issues.value}"
elif values.get("metric_type") == MetricType.timeseries:
assert isinstance(values.get("view_type"), MetricTimeseriesViewType), \
f"viewType must be of type {MetricTimeseriesViewType} for metricType:{MetricType.timeseries.value}"
assert isinstance(values.get("metric_of"), TimeseriesMetricOfType), \
f"metricOf must be of type {TimeseriesMetricOfType} for metricType:{MetricType.timeseries.value}"
return values
class Config:
alias_generator = attribute_to_camel_case
class CustomMetricUpdateSeriesSchema(CustomMetricCreateSeriesSchema):

View file

@ -1,41 +1,40 @@
package cache
import (
import (
"errors"
. "openreplay/backend/pkg/messages"
. "openreplay/backend/pkg/db/types"
. "openreplay/backend/pkg/messages"
)
func (c *PGCache) InsertIOSSessionStart(sessionID uint64, s *IOSSessionStart) error {
if c.sessions[ sessionID ] != nil {
if c.sessions[sessionID] != nil {
return errors.New("This session already in cache!")
}
c.sessions[ sessionID ] = &Session{
SessionID: sessionID,
Platform: "ios",
Timestamp: s.Timestamp,
ProjectID: uint32(s.ProjectID),
c.sessions[sessionID] = &Session{
SessionID: sessionID,
Platform: "ios",
Timestamp: s.Timestamp,
ProjectID: uint32(s.ProjectID),
TrackerVersion: s.TrackerVersion,
RevID: s.RevID,
UserUUID: s.UserUUID,
UserOS: s.UserOS,
UserOSVersion: s.UserOSVersion,
UserDevice: s.UserDevice,
UserCountry: s.UserCountry,
RevID: s.RevID,
UserUUID: s.UserUUID,
UserOS: s.UserOS,
UserOSVersion: s.UserOSVersion,
UserDevice: s.UserDevice,
UserCountry: s.UserCountry,
UserDeviceType: s.UserDeviceType,
}
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[ sessionID ]); err != nil {
c.sessions[ sessionID ] = nil
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[sessionID]); err != nil {
c.sessions[sessionID] = nil
return err
}
return nil;
return nil
}
func (c *PGCache) InsertIOSSessionEnd(sessionID uint64, e *IOSSessionEnd) error {
return c.insertSessionEnd(sessionID, e.Timestamp)
}
func (c *PGCache) InsertIOSScreenEnter(sessionID uint64, screenEnter *IOSScreenEnter) error {
if err := c.Conn.InsertIOSScreenEnter(sessionID, screenEnter); err != nil {
return err
@ -95,4 +94,3 @@ func (c *PGCache) InsertIOSIssueEvent(sessionID uint64, issueEvent *IOSIssueEven
// }
return nil
}

View file

@ -53,3 +53,27 @@ func (c *PGCache) InsertWebErrorEvent(sessionID uint64, e *ErrorEvent) error {
session.ErrorsCount += 1
return nil
}
func (c *PGCache) InsertWebFetchEvent(sessionID uint64, e *FetchEvent) error {
session, err := c.GetSession(sessionID)
if err != nil {
return err
}
project, err := c.GetProject(session.ProjectID)
if err != nil {
return err
}
return c.Conn.InsertWebFetchEvent(sessionID, project.SaveRequestPayloads, e)
}
func (c *PGCache) InsertWebGraphQLEvent(sessionID uint64, e *GraphQLEvent) error {
session, err := c.GetSession(sessionID)
if err != nil {
return err
}
project, err := c.GetProject(session.ProjectID)
if err != nil {
return err
}
return c.Conn.InsertWebGraphQLEvent(sessionID, project.SaveRequestPayloads, e)
}

View file

@ -1,8 +1,8 @@
package cache
import (
"time"
import (
"sync"
"time"
"openreplay/backend/pkg/db/postgres"
. "openreplay/backend/pkg/db/types"
@ -10,32 +10,29 @@ import (
type ProjectMeta struct {
*Project
expirationTime time.Time
expirationTime time.Time
}
// !TODO: remove old sessions by timeout to avoid memleaks
/*
/*
* Cache layer around the stateless PG adapter
**/
type PGCache struct {
*postgres.Conn
sessions map[uint64]*Session
projects map[uint32]*ProjectMeta
projectsByKeys sync.Map // map[string]*ProjectMeta
sessions map[uint64]*Session
projects map[uint32]*ProjectMeta
projectsByKeys sync.Map // map[string]*ProjectMeta
projectExpirationTimeout time.Duration
}
// TODO: create conn automatically
func NewPGCache(pgConn *postgres.Conn, projectExpirationTimeoutMs int64) *PGCache {
return &PGCache{
Conn: pgConn,
Conn: pgConn,
sessions: make(map[uint64]*Session),
projects: make(map[uint32]*ProjectMeta),
//projectsByKeys: make(map[string]*ProjectMeta),
projectExpirationTimeout: time.Duration(1000 * projectExpirationTimeoutMs),
}
}

View file

@ -1,8 +1,8 @@
package cache
import (
"time"
import (
. "openreplay/backend/pkg/db/types"
"time"
)
func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
@ -24,19 +24,16 @@ func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
return p, nil
}
func (c *PGCache) GetProject(projectID uint32) (*Project, error) {
if c.projects[ projectID ] != nil &&
time.Now().Before(c.projects[ projectID ].expirationTime) {
return c.projects[ projectID ].Project, nil
if c.projects[projectID] != nil &&
time.Now().Before(c.projects[projectID].expirationTime) {
return c.projects[projectID].Project, nil
}
p, err := c.Conn.GetProject(projectID)
if err != nil {
return nil, err
}
c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
c.projects[projectID] = &ProjectMeta{p, time.Now().Add(c.projectExpirationTimeout)}
//c.projectsByKeys.Store(p.ProjectKey, c.projects[ projectID ])
return p, nil
}

View file

@ -1,13 +1,13 @@
package cache
import (
import (
"github.com/jackc/pgx/v4"
. "openreplay/backend/pkg/db/types"
)
func (c *PGCache) GetSession(sessionID uint64) (*Session, error) {
if s, inCache := c.sessions[ sessionID ]; inCache {
if s, inCache := c.sessions[sessionID]; inCache {
// TODO: review. Might cause bugs in case of multiple instances
if s == nil {
return nil, pgx.ErrNoRows
@ -16,12 +16,12 @@ func (c *PGCache) GetSession(sessionID uint64) (*Session, error) {
}
s, err := c.Conn.GetSession(sessionID)
if err == pgx.ErrNoRows {
c.sessions[ sessionID ] = nil
c.sessions[sessionID] = nil
}
if err != nil {
return nil, err
}
c.sessions[ sessionID ] = s
c.sessions[sessionID] = s
return s, nil
}

View file

@ -3,14 +3,14 @@ package postgres
import (
"errors"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgconn"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v4"
)
func IsPkeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
}
func IsNoRowsErr(err error) bool {

View file

@ -8,7 +8,7 @@ func getIssueScore(issueEvent *messages.IssueEvent) int {
switch issueEvent.Type {
case "crash", "dead_click", "memory", "cpu":
return 1000
case "bad_request", "excessive_scrolling", "click_rage", "missing_resource" :
case "bad_request", "excessive_scrolling", "click_rage", "missing_resource":
return 500
case "slow_resource", "slow_page_load":
return 100
@ -32,4 +32,4 @@ func calcResponseTime(pe *messages.PageEvent) uint64 {
return pe.ResponseEnd - pe.ResponseStart
}
return 0
}
}

View file

@ -6,8 +6,6 @@ import (
"fmt"
"github.com/jackc/pgx/v4"
)
type Listener struct {

View file

@ -1,8 +1,8 @@
package postgres
import (
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/hashid"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/url"
)
@ -33,7 +33,7 @@ func (conn *Conn) InsertIOSUserAnonymousID(sessionID uint64, userAnonymousID *me
func (conn *Conn) InsertIOSNetworkCall(sessionID uint64, e *messages.IOSNetworkCall) error {
err := conn.InsertRequest(sessionID, e.Timestamp, e.Index, e.URL, e.Duration, e.Success)
if err == nil {
conn.insertAutocompleteValue(sessionID, "REQUEST_IOS", url.DiscardURLQuery(e.URL))
conn.insertAutocompleteValue(sessionID, "REQUEST_IOS", url.DiscardURLQuery(e.URL))
}
return err
}
@ -65,7 +65,7 @@ func (conn *Conn) InsertIOSScreenEnter(sessionID uint64, screenEnter *messages.I
if err = tx.commit(); err != nil {
return err
}
conn.insertAutocompleteValue(sessionID, "VIEW_IOS", screenEnter.ViewName)
conn.insertAutocompleteValue(sessionID, "VIEW_IOS", screenEnter.ViewName)
return nil
}
@ -81,7 +81,7 @@ func (conn *Conn) InsertIOSClickEvent(sessionID uint64, clickEvent *messages.IOS
session_id, timestamp, seq_index, label
) VALUES (
$1, $2, $3, $4
)`,
)`,
sessionID, clickEvent.Timestamp, clickEvent.Index, clickEvent.Label,
); err != nil {
return err
@ -153,7 +153,7 @@ func (conn *Conn) InsertIOSCrash(sessionID uint64, projectID uint32, crash *mess
project_id, $2, $3, $4, $5
FROM sessions
WHERE session_id = $1
)ON CONFLICT DO NOTHING`,
)ON CONFLICT DO NOTHING`,
sessionID, crashID, crash.Name, crash.Reason, crash.Stacktrace,
); err != nil {
return err
@ -163,7 +163,7 @@ func (conn *Conn) InsertIOSCrash(sessionID uint64, projectID uint32, crash *mess
session_id, timestamp, seq_index, crash_id
) VALUES (
$1, $2, $3, $4
)`,
)`,
sessionID, crash.Timestamp, crash.Index, crashID,
); err != nil {
return err
@ -177,5 +177,3 @@ func (conn *Conn) InsertIOSCrash(sessionID uint64, projectID uint32, crash *mess
}
return tx.commit()
}

View file

@ -13,9 +13,8 @@ func getSqIdx(messageID uint64) uint {
return uint(messageID % math.MaxInt32)
}
func (conn *Conn) InsertWebCustomEvent(sessionID uint64, e *CustomEvent) error {
err := conn.InsertCustomEvent(sessionID, e.Timestamp,
err := conn.InsertCustomEvent(sessionID, e.Timestamp,
e.MessageID,
e.Name, e.Payload)
if err == nil {
@ -40,19 +39,19 @@ func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, userAnonymousID *Us
return err
}
func (conn *Conn) InsertWebResourceEvent(sessionID uint64, e *ResourceEvent) error {
if e.Type != "fetch" {
return nil
}
err := conn.InsertRequest(sessionID, e.Timestamp,
e.MessageID,
e.URL, e.Duration, e.Success,
)
if err == nil {
conn.insertAutocompleteValue(sessionID, "REQUEST", url.DiscardURLQuery(e.URL))
}
return err
}
// func (conn *Conn) InsertWebResourceEvent(sessionID uint64, e *ResourceEvent) error {
// if e.Type != "fetch" {
// return nil
// }
// err := conn.InsertRequest(sessionID, e.Timestamp,
// e.MessageID,
// e.URL, e.Duration, e.Success,
// )
// if err == nil {
// conn.insertAutocompleteValue(sessionID, "REQUEST", url.DiscardURLQuery(e.URL))
// }
// return err
// }
// TODO: fix column "dom_content_loaded_event_end" of relation "pages"
func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
@ -62,7 +61,7 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
}
tx, err := conn.begin()
if err != nil {
return err
return err
}
defer tx.rollback()
if err := tx.exec(`
@ -79,7 +78,7 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
)
`,
sessionID, e.MessageID, e.Timestamp, e.Referrer, url.DiscardURLQuery(e.Referrer), host, path, url.DiscardURLQuery(path),
e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint,
e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint,
e.SpeedIndex, e.VisuallyComplete, e.TimeToInteractive,
calcResponseTime(e), calcDomBuildingTime(e),
); err != nil {
@ -133,7 +132,6 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, e *ClickEvent) error {
return nil
}
func (conn *Conn) InsertWebInputEvent(sessionID uint64, e *InputEvent) error {
tx, err := conn.begin()
if err != nil {
@ -205,3 +203,50 @@ func (conn *Conn) InsertWebErrorEvent(sessionID uint64, projectID uint32, e *Err
}
return tx.commit()
}
func (conn *Conn) InsertWebFetchEvent(sessionID uint64, savePayload bool, e *FetchEvent) error {
var request, response *string
if savePayload {
request = &e.Request
response = &e.Response
}
conn.insertAutocompleteValue(sessionID, "REQUEST", url.DiscardURLQuery(e.URL))
return conn.batchQueue(sessionID, `
INSERT INTO events_common.requests (
session_id, timestamp,
seq_index, url, duration, success,
request_body, response_body, status_code, method
) VALUES (
$1, $2,
$3, $4, $5, $6,
$7, $8, $9::smallint, NULLIF($10, '')::events_common.http_method
) ON CONFLICT DO NOTHING`,
sessionID, e.Timestamp,
getSqIdx(e.MessageID), e.URL, e.Duration, e.Status < 400,
request, response, e.Status, url.EnsureMethod(e.Method),
)
}
func (conn *Conn) InsertWebGraphQLEvent(sessionID uint64, savePayload bool, e *GraphQLEvent) error {
var request, response *string
if savePayload {
request = &e.Variables
response = &e.Response
}
conn.insertAutocompleteValue(sessionID, "GRAPHQL", e.OperationName)
return conn.batchQueue(sessionID, `
INSERT INTO events.graphql (
session_id, timestamp, message_id,
name,
request_body, response_body
) VALUES (
$1, $2, $3,
$4,
$5, $6
) ON CONFLICT DO NOTHING`,
sessionID, e.Timestamp, e.MessageID,
e.OperationName,
request, response,
)
}

View file

@ -14,7 +14,7 @@ type TenantNotification struct {
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
ButtonText string `db:"button_text" json:"buttonText"`
ButtonUrl string `db:"button_url" json:"buttonUrl"`
ButtonUrl string `db:"button_url" json:"buttonUrl"`
ImageUrl *string `db:"image_url" json:"imageUrl"`
Options map[string]interface{} `db:"options" json:"options"`
}

View file

@ -5,7 +5,7 @@ import (
)
func (conn *Conn) GetProjectByKey(projectKey string) (*Project, error) {
p := &Project{ ProjectKey: projectKey }
p := &Project{ProjectKey: projectKey}
if err := conn.queryRow(`
SELECT max_session_duration, sample_rate, project_id
FROM projects
@ -20,19 +20,19 @@ func (conn *Conn) GetProjectByKey(projectKey string) (*Project, error) {
// TODO: logical separation of metadata
func (conn *Conn) GetProject(projectID uint32) (*Project, error) {
p := &Project{ ProjectID: projectID }
p := &Project{ProjectID: projectID}
if err := conn.queryRow(`
SELECT project_key, max_session_duration,
SELECT project_key, max_session_duration, save_request_payloads,
metadata_1, metadata_2, metadata_3, metadata_4, metadata_5,
metadata_6, metadata_7, metadata_8, metadata_9, metadata_10
FROM projects
WHERE project_id=$1 AND active = true
`,
projectID,
).Scan(&p.ProjectKey,&p.MaxSessionDuration,
&p.Metadata1, &p.Metadata2, &p.Metadata3, &p.Metadata4, &p.Metadata5,
&p.Metadata6, &p.Metadata7, &p.Metadata8, &p.Metadata9, &p.Metadata10); err != nil {
).Scan(&p.ProjectKey, &p.MaxSessionDuration, &p.SaveRequestPayloads,
&p.Metadata1, &p.Metadata2, &p.Metadata3, &p.Metadata4, &p.Metadata5,
&p.Metadata6, &p.Metadata7, &p.Metadata8, &p.Metadata9, &p.Metadata10); err != nil {
return nil, err
}
return p, nil
}
}

View file

@ -1,18 +1,18 @@
package postgres
type UnstartedSession struct {
ProjectKey string
TrackerVersion string
DoNotTrack bool
Platform string
UserAgent string
UserOS string
UserOSVersion string
UserBrowser string
ProjectKey string
TrackerVersion string
DoNotTrack bool
Platform string
UserAgent string
UserOS string
UserOSVersion string
UserBrowser string
UserBrowserVersion string
UserDevice string
UserDeviceType string
UserCountry string
UserDevice string
UserDeviceType string
UserCountry string
}
func (conn *Conn) InsertUnstartedSession(s UnstartedSession) error {
@ -34,12 +34,12 @@ func (conn *Conn) InsertUnstartedSession(s UnstartedSession) error {
$10, $11,
$12
)`,
s.ProjectKey,
s.ProjectKey,
s.TrackerVersion, s.DoNotTrack,
s.Platform, s.UserAgent,
s.UserOS, s.UserOSVersion,
s.UserBrowser, s.UserBrowserVersion,
s.UserDevice, s.UserDeviceType,
s.UserDevice, s.UserDeviceType,
s.UserCountry,
)
}

View file

@ -3,23 +3,23 @@ package types
import "log"
type Project struct {
ProjectID uint32
ProjectKey string
MaxSessionDuration int64
SampleRate byte
Metadata1 *string
Metadata2 *string
Metadata3 *string
Metadata4 *string
Metadata5 *string
Metadata6 *string
Metadata7 *string
Metadata8 *string
Metadata9 *string
Metadata10 *string
ProjectID uint32
ProjectKey string
MaxSessionDuration int64
SampleRate byte
SaveRequestPayloads bool
Metadata1 *string
Metadata2 *string
Metadata3 *string
Metadata4 *string
Metadata5 *string
Metadata6 *string
Metadata7 *string
Metadata8 *string
Metadata9 *string
Metadata10 *string
}
func (p *Project) GetMetadataNo(key string) uint {
if p == nil {
log.Printf("GetMetadataNo: Project is nil")

View file

@ -12,10 +12,10 @@ type Session struct {
UserDevice string
UserCountry string
Duration *uint64
PagesCount int
EventsCount int
ErrorsCount int
Duration *uint64
PagesCount int
EventsCount int
ErrorsCount int
UserID *string // pointer??
UserAnonymousID *string

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ func insertMessage(sessionID uint64, msg Message) error {
return pg.InsertMetadata(sessionID, m)
case *IssueEvent:
return pg.InsertIssueEvent(sessionID, m)
//TODO: message adapter (transformer) (at the level of pkg/message) for types:
//TODO: message adapter (transformer) (at the level of pkg/message) for types:
// case *IOSMetadata, *IOSIssueEvent and others
// Web
@ -30,14 +30,18 @@ func insertMessage(sessionID uint64, msg Message) error {
case *InputEvent:
return pg.InsertWebInputEvent(sessionID, m)
// Unique Web messages
case *ResourceEvent:
return pg.InsertWebResourceEvent(sessionID, m)
// case *ResourceEvent:
// return pg.InsertWebResourceEvent(sessionID, m)
case *PageEvent:
return pg.InsertWebPageEvent(sessionID, m)
case *ErrorEvent:
case *ErrorEvent:
return pg.InsertWebErrorEvent(sessionID, m)
case *FetchEvent:
return pg.InsertWebFetchEvent(sessionID, m)
case *GraphQLEvent:
return pg.InsertWebGraphQLEvent(sessionID, m)
// IOS
// IOS
case *IOSSessionStart:
return pg.InsertIOSSessionStart(sessionID, m)
case *IOSSessionEnd:
@ -57,8 +61,8 @@ func insertMessage(sessionID uint64, msg Message) error {
return pg.InsertIOSNetworkCall(sessionID, m)
case *IOSScreenEnter:
return pg.InsertIOSScreenEnter(sessionID, m)
case *IOSCrash:
case *IOSCrash:
return pg.InsertIOSCrash(sessionID, m)
}
return nil // "Not implemented"
}
return nil // "Not implemented"
}

View file

@ -41,33 +41,32 @@ func getResourceType(initiator string, URL string) string {
}
type builder struct {
readyMsgs []Message
timestamp uint64
lastProcessedTimestamp int64
peBuilder *pageEventBuilder
ptaBuilder *performanceTrackAggrBuilder
ieBuilder *inputEventBuilder
ciFinder *cpuIssueFinder
miFinder *memoryIssueFinder
ddDetector *domDropDetector
crDetector *clickRageDetector
dcDetector *deadClickDetector
integrationsWaiting bool
readyMsgs []Message
timestamp uint64
lastProcessedTimestamp int64
peBuilder *pageEventBuilder
ptaBuilder *performanceTrackAggrBuilder
ieBuilder *inputEventBuilder
ciFinder *cpuIssueFinder
miFinder *memoryIssueFinder
ddDetector *domDropDetector
crDetector *clickRageDetector
dcDetector *deadClickDetector
integrationsWaiting bool
sid uint64
}
func NewBuilder() *builder {
return &builder{
peBuilder: &pageEventBuilder{},
ptaBuilder: &performanceTrackAggrBuilder{},
ieBuilder: NewInputEventBuilder(),
ciFinder: &cpuIssueFinder{},
miFinder: &memoryIssueFinder{},
ddDetector: &domDropDetector{},
crDetector: &clickRageDetector{},
dcDetector: &deadClickDetector{},
peBuilder: &pageEventBuilder{},
ptaBuilder: &performanceTrackAggrBuilder{},
ieBuilder: NewInputEventBuilder(),
ciFinder: &cpuIssueFinder{},
miFinder: &memoryIssueFinder{},
ddDetector: &domDropDetector{},
crDetector: &clickRageDetector{},
dcDetector: &deadClickDetector{},
integrationsWaiting: true,
}
}
@ -115,15 +114,14 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
b.timestamp = timestamp
}
b.lastProcessedTimestamp = time.Now().UnixNano()/1e6
b.lastProcessedTimestamp = time.Now().UnixNano() / 1e6
// Might happen before the first timestamp.
switch msg := message.(type) {
case *SessionStart,
*Metadata,
*UserID,
*UserAnonymousID:
*Metadata,
*UserID,
*UserAnonymousID:
b.appendReadyMessage(msg)
case *RawErrorEvent:
b.appendReadyMessage(&ErrorEvent{
@ -220,14 +218,14 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
Type: tp,
Success: success,
})
if !success && tp == "fetch" {
b.appendReadyMessage(&IssueEvent{
Type: "bad_request",
MessageID: messageID,
Timestamp: msg.Timestamp,
if !success && tp == "fetch" {
b.appendReadyMessage(&IssueEvent{
Type: "bad_request",
MessageID: messageID,
Timestamp: msg.Timestamp,
ContextString: msg.URL,
Context: "",
Payload: "",
Context: "",
Payload: "",
})
}
case *RawCustomEvent:
@ -239,22 +237,31 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
})
case *CustomIssue:
b.appendReadyMessage(&IssueEvent{
Type: "custom",
Timestamp: b.timestamp,
MessageID: messageID,
Type: "custom",
Timestamp: b.timestamp,
MessageID: messageID,
ContextString: msg.Name,
Payload: msg.Payload,
Payload: msg.Payload,
})
case *Fetch:
b.appendReadyMessage(&ResourceEvent{
b.appendReadyMessage(&FetchEvent{
MessageID: messageID,
Timestamp: msg.Timestamp,
Duration: msg.Duration,
URL: msg.URL,
Type: "fetch",
Success: msg.Status < 300,
Method: msg.Method,
URL: msg.URL,
Request: msg.Request,
Response: msg.Response,
Status: msg.Status,
Duration: msg.Duration,
})
case *GraphQL:
b.appendReadyMessage(&GraphQLEvent{
MessageID: messageID,
Timestamp: b.timestamp,
OperationKind: msg.OperationKind,
OperationName: msg.OperationName,
Variables: msg.Variables,
Response: msg.Response,
})
case *StateAction:
b.appendReadyMessage(&StateActionEvent{
@ -262,12 +269,6 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
Timestamp: b.timestamp,
Type: msg.Type,
})
case *GraphQL:
b.appendReadyMessage(&GraphQLEvent{
MessageID: messageID,
Timestamp: b.timestamp,
Name: msg.OperationName,
})
case *CreateElementNode,
*CreateTextNode:
b.ddDetector.HandleNodeCreation()
@ -283,11 +284,10 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
}
}
func (b *builder) checkTimeouts(ts int64) bool {
if b.timestamp == 0 {
if b.timestamp == 0 {
return false // There was no timestamp events yet
}
}
if b.peBuilder.HasInstance() && int64(b.peBuilder.GetTimestamp())+intervals.EVENTS_PAGE_EVENT_TIMEOUT < ts {
b.buildPageEvent()

View file

@ -81,20 +81,25 @@ def get_projects(tenant_id, recording_state=False, gdpr=None, recorded=False, st
)
rows = cur.fetchall()
if recording_state:
project_ids = [f'({r["project_id"]})' for r in rows]
query = f"""SELECT projects.project_id, COALESCE(MAX(start_ts), 0) AS last
FROM (VALUES {",".join(project_ids)}) AS projects(project_id)
LEFT JOIN sessions USING (project_id)
GROUP BY project_id;"""
cur.execute(
query=query
)
status = cur.fetchall()
for r in rows:
query = cur.mogrify(
"select COALESCE(MAX(start_ts),0) AS last from public.sessions where project_id=%(project_id)s;",
{"project_id": r["project_id"]})
cur.execute(
query=query
)
status = cur.fetchone()
if status["last"] < TimeUTC.now(-2):
r["status"] = "red"
elif status["last"] < TimeUTC.now(-1):
r["status"] = "yellow"
else:
r["status"] = "green"
for s in status:
if s["project_id"] == r["project_id"]:
if s["last"] < TimeUTC.now(-2):
r["status"] = "red"
elif s["last"] < TimeUTC.now(-1):
r["status"] = "yellow"
else:
r["status"] = "green"
break
return helper.list_to_camel_case(rows)

View file

@ -1,7 +1,5 @@
import json
from decouple import config
import schemas
from chalicelib.core import users, telemetry, tenants
from chalicelib.utils import captcha
@ -63,12 +61,11 @@ def create_step1(data: schemas.UserSignupSchema):
params = {"email": email, "password": password,
"fullname": fullname, "companyName": company_name,
"projectName": project_name,
"versionNumber": config("version_number"),
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})}
query = """\
WITH t AS (
INSERT INTO public.tenants (name, version_number, edition)
VALUES (%(companyName)s, %(versionNumber)s, 'ee')
VALUES (%(companyName)s, (SELECT openreplay_version()), 'ee')
RETURNING tenant_id, api_key
),
r AS (

View file

@ -0,0 +1,100 @@
BEGIN;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
$$
SELECT 'v1.5.3-ee'
$$ LANGUAGE sql IMMUTABLE;
UPDATE metrics
SET is_public= TRUE;
DO
$$
BEGIN
IF NOT EXISTS(SELECT *
FROM pg_type typ
INNER JOIN pg_namespace nsp
ON nsp.oid = typ.typnamespace
WHERE nsp.nspname = current_schema()
AND typ.typname = 'metric_type') THEN
CREATE TYPE metric_type AS ENUM ('timeseries','table');
END IF;
END;
$$
LANGUAGE plpgsql;
DO
$$
BEGIN
IF NOT EXISTS(SELECT *
FROM pg_type typ
INNER JOIN pg_namespace nsp
ON nsp.oid = typ.typnamespace
WHERE nsp.nspname = current_schema()
AND typ.typname = 'metric_view_type') THEN
CREATE TYPE metric_view_type AS ENUM ('lineChart','progress','table','pieChart');
END IF;
END;
$$
LANGUAGE plpgsql;
ALTER TABLE metrics
ADD COLUMN IF NOT EXISTS
metric_type metric_type NOT NULL DEFAULT 'timeseries',
ADD COLUMN IF NOT EXISTS
view_type metric_view_type NOT NULL DEFAULT 'lineChart',
ADD COLUMN IF NOT EXISTS
metric_of text NOT NULL DEFAULT 'sessionCount',
ADD COLUMN IF NOT EXISTS
metric_value text[] NOT NULL DEFAULT '{}'::text[],
ADD COLUMN IF NOT EXISTS
metric_format text;
DO
$$
BEGIN
IF NOT EXISTS(SELECT *
FROM pg_type typ
INNER JOIN pg_namespace nsp
ON nsp.oid = typ.typnamespace
WHERE typ.typname = 'http_method') THEN
CREATE TYPE http_method AS ENUM ('GET','HEAD','POST','PUT','DELETE','CONNECT','OPTIONS','TRACE','PATCH');
END IF;
END;
$$
LANGUAGE plpgsql;
ALTER TABLE events.graphql
ADD COLUMN IF NOT EXISTS request_body text NULL,
ADD COLUMN IF NOT EXISTS response_body text NULL,
ADD COLUMN IF NOT EXISTS status_code smallint NULL,
ADD COLUMN IF NOT EXISTS method http_method NULL,
ADD COLUMN IF NOT EXISTS duration integer NULL;
ALTER TABLE events_common.requests
ADD COLUMN IF NOT EXISTS request_body text NULL,
ADD COLUMN IF NOT EXISTS response_body text NULL,
ADD COLUMN IF NOT EXISTS status_code smallint NULL,
ADD COLUMN IF NOT EXISTS method http_method NULL;
UPDATE tenants
SET version_number= openreplay_version();
COMMIT;
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_request_body_nn_idx ON events_common.requests (request_body) WHERE request_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_request_body_nn_gin_idx ON events_common.requests USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_response_body_nn_idx ON events_common.requests (response_body) WHERE response_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_response_body_nn_gin_idx ON events_common.requests USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_status_code_nn_idx ON events_common.requests (status_code) WHERE status_code IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_request_body_nn_idx ON events.graphql (request_body) WHERE request_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_request_body_nn_gin_idx ON events.graphql USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_response_body_nn_idx ON events.graphql (response_body) WHERE response_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_response_body_nn_gin_idx ON events.graphql USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_status_code_nn_idx ON events.graphql (status_code) WHERE status_code IS NOT NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_duration_nn_gt0_idx ON events.graphql (duration) WHERE duration IS NOT NULL AND duration > 0;

View file

@ -7,7 +7,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
$$
SELECT 'v1.5.2-ee'
SELECT 'v1.5.3-ee'
$$ LANGUAGE sql IMMUTABLE;
@ -770,16 +770,23 @@ $$
CREATE INDEX IF NOT EXISTS traces_user_id_idx ON traces (user_id);
CREATE INDEX IF NOT EXISTS traces_tenant_id_idx ON traces (tenant_id);
CREATE TYPE metric_type AS ENUM ('timeseries','table');
CREATE TYPE metric_view_type AS ENUM ('lineChart','progress','table','pieChart');
CREATE TABLE IF NOT EXISTS metrics
(
metric_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
project_id integer NOT NULL REFERENCES projects (project_id) ON DELETE CASCADE,
user_id integer REFERENCES users (user_id) ON DELETE SET NULL,
name text NOT NULL,
is_public boolean NOT NULL DEFAULT FALSE,
active boolean NOT NULL DEFAULT TRUE,
created_at timestamp default timezone('utc'::text, now()) not null,
deleted_at timestamp
metric_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
project_id integer NOT NULL REFERENCES projects (project_id) ON DELETE CASCADE,
user_id integer REFERENCES users (user_id) ON DELETE SET NULL,
name text NOT NULL,
is_public boolean NOT NULL DEFAULT FALSE,
active boolean NOT NULL DEFAULT TRUE,
created_at timestamp DEFAULT timezone('utc'::text, now()) not null,
deleted_at timestamp,
metric_type metric_type NOT NULL DEFAULT 'timeseries',
view_type metric_view_type NOT NULL DEFAULT 'lineChart',
metric_of text NOT NULL DEFAULT 'sessionCount',
metric_value text[] NOT NULL DEFAULT '{}'::text[],
metric_format text
);
CREATE INDEX IF NOT EXISTS metrics_user_id_is_public_idx ON public.metrics (user_id, is_public);
CREATE TABLE IF NOT EXISTS metric_series
@ -987,7 +994,11 @@ $$
CREATE INDEX IF NOT EXISTS errors_error_id_timestamp_session_id_idx ON events.errors (error_id, timestamp, session_id);
CREATE INDEX IF NOT EXISTS errors_error_id_idx ON events.errors (error_id);
IF NOT EXISTS(SELECT *
FROM pg_type typ
WHERE typ.typname = 'http_method') THEN
CREATE TYPE http_method AS ENUM ('GET','HEAD','POST','PUT','DELETE','CONNECT','OPTIONS','TRACE','PATCH');
END IF;
CREATE TABLE IF NOT EXISTS events.graphql
(
session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE,
@ -999,6 +1010,12 @@ $$
CREATE INDEX IF NOT EXISTS graphql_name_idx ON events.graphql (name);
CREATE INDEX IF NOT EXISTS graphql_name_gin_idx ON events.graphql USING GIN (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS graphql_timestamp_idx ON events.graphql (timestamp);
CREATE INDEX IF NOT EXISTS graphql_request_body_nn_idx ON events.graphql (request_body) WHERE request_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS graphql_request_body_nn_gin_idx ON events.graphql USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS graphql_response_body_nn_idx ON events.graphql (response_body) WHERE response_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS graphql_response_body_nn_gin_idx ON events.graphql USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS graphql_status_code_nn_idx ON events.graphql (status_code) WHERE status_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS graphql_duration_nn_gt0_idx ON events.graphql (duration) WHERE duration IS NOT NULL AND duration > 0;
CREATE TABLE IF NOT EXISTS events.state_actions
(
@ -1140,15 +1157,23 @@ $$
CREATE INDEX IF NOT EXISTS issues_issue_id_timestamp_idx ON events_common.issues (issue_id, timestamp);
CREATE INDEX IF NOT EXISTS issues_timestamp_idx ON events_common.issues (timestamp);
IF NOT EXISTS(SELECT *
FROM pg_type typ
WHERE typ.typname = 'http_method') THEN
CREATE TYPE http_method AS ENUM ('GET','HEAD','POST','PUT','DELETE','CONNECT','OPTIONS','TRACE','PATCH');
END IF;
CREATE TABLE IF NOT EXISTS events_common.requests
(
session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE,
timestamp bigint NOT NULL,
seq_index integer NOT NULL,
url text NOT NULL,
duration integer NOT NULL,
success boolean NOT NULL,
session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE,
timestamp bigint NOT NULL,
seq_index integer NOT NULL,
url text NOT NULL,
duration integer NOT NULL,
success boolean NOT NULL,
request_body text NULL,
response_body text NULL,
status_code smallint NULL,
method http_method NULL,
PRIMARY KEY (session_id, timestamp, seq_index)
);
CREATE INDEX IF NOT EXISTS requests_url_idx ON events_common.requests (url);
@ -1165,6 +1190,13 @@ $$
ELSE 0 END))
gin_trgm_ops);
CREATE INDEX IF NOT EXISTS requests_timestamp_session_id_failed_idx ON events_common.requests (timestamp, session_id) WHERE success = FALSE;
CREATE INDEX IF NOT EXISTS requests_request_body_nn_idx ON events_common.requests (request_body) WHERE request_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS requests_request_body_nn_gin_idx ON events_common.requests USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS requests_response_body_nn_idx ON events_common.requests (response_body) WHERE response_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS requests_response_body_nn_gin_idx ON events_common.requests USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
CREATE INDEX IF NOT EXISTS requests_status_code_nn_idx ON events_common.requests (status_code) WHERE status_code IS NOT NULL;
END IF;
END;
$$

13
ee/utilities/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
.idea
node_modules
npm-debug.log
.cache
test.html
build.sh
servers/peerjs-server.js
servers/sourcemaps-handler.js
servers/sourcemaps-server.js
#servers/websocket.js

2699
ee/utilities/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
ee/utilities/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "utilities_server",
"version": "1.0.0",
"description": "assist server to get live sessions & sourcemaps reader to get stack trace",
"main": "peerjs-server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/openreplay/openreplay.git"
},
"author": "KRAIEM Taha Yassine <tahayk2@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/openreplay/openreplay/issues"
},
"homepage": "https://github.com/openreplay/openreplay#readme",
"dependencies": {
"@maxmind/geoip2-node": "^3.4.0",
"@socket.io/redis-adapter": "^7.1.0",
"aws-sdk": "^2.992.0",
"express": "^4.17.1",
"peer": "^0.6.1",
"redis": "^4.0.3",
"socket.io": "^4.4.1",
"source-map": "^0.7.3",
"ua-parser-js": "^1.0.2",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.6.0"
}
}

128
ee/utilities/server.js Normal file
View file

@ -0,0 +1,128 @@
var sourcemapsReaderServer = require('./servers/sourcemaps-server');
var {peerRouter, peerConnection, peerDisconnect, peerError} = require('./servers/peerjs-server');
var express = require('express');
const {ExpressPeerServer} = require('peer');
var socket;
if (process.env.cluster === "true") {
console.log("Using Redis");
socket = require("./servers/websocket-cluster");
} else {
socket = require("./servers/websocket");
}
const HOST = '0.0.0.0';
const PORT = 9000;
var app = express();
let debug = process.env.debug === "1" || false;
const request_logger = (identity) => {
return (req, res, next) => {
debug && console.log(identity, new Date().toTimeString(), 'REQUEST', req.method, req.originalUrl);
res.on('finish', function () {
if (this.statusCode !== 200 || debug) {
console.log(new Date().toTimeString(), 'RESPONSE', req.method, req.originalUrl, this.statusCode);
}
})
next();
}
};
app.use(request_logger("[app]"));
app.use('/sourcemaps', sourcemapsReaderServer);
app.use('/assist', peerRouter);
const server = app.listen(PORT, HOST, () => {
console.log(`App listening on http://${HOST}:${PORT}`);
console.log('Press Ctrl+C to quit.');
});
const peerServer = ExpressPeerServer(server, {
debug: true,
path: '/',
proxied: true,
allow_discovery: false
});
peerServer.on('connection', peerConnection);
peerServer.on('disconnect', peerDisconnect);
peerServer.on('error', peerError);
app.use('/', peerServer);
app.enable('trust proxy');
if (process.env.uws !== "true") {
var wsapp = express();
wsapp.use(request_logger("[wsapp]"));
wsapp.use('/assist', socket.wsRouter);
const wsserver = wsapp.listen(PORT + 1, HOST, () => {
console.log(`WS App listening on http://${HOST}:${PORT + 1}`);
console.log('Press Ctrl+C to quit.');
});
wsapp.enable('trust proxy');
socket.start(wsserver);
module.exports = {wsserver, server};
} else {
console.log("Using uWebSocket");
const {App} = require("uWebSockets.js");
const PREFIX = process.env.prefix || '/assist'
const uapp = new App();
const healthFn = (res, req) => {
res.writeStatus('200 OK').end('ok!');
}
uapp.get(PREFIX, healthFn);
uapp.get(`${PREFIX}/`, healthFn);
/* Either onAborted or simply finished request */
const onAbortedOrFinishedResponse = function (res, readStream) {
if (res.id === -1) {
debug && console.log("ERROR! onAbortedOrFinishedResponse called twice for the same res!");
} else {
debug && console.log('Stream was closed');
console.timeEnd(res.id);
readStream.destroy();
}
/* Mark this response already accounted for */
res.id = -1;
}
const uWrapper = function (fn) {
return (res, req) => {
res.id = 1;
res.onAborted(() => {
onAbortedOrFinishedResponse(res, readStream);
});
return fn(req, res);
}
}
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-list`, uWrapper(socket.handlers.socketsList));
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-list/:projectKey`, uWrapper(socket.handlers.socketsListByProject));
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-live`, uWrapper(socket.handlers.socketsLive));
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-live/:projectKey`, uWrapper(socket.handlers.socketsLiveByProject));
socket.start(uapp);
uapp.listen(HOST, PORT + 1, (token) => {
if (!token) {
console.warn("port already in use");
}
console.log(`WS App listening on http://${HOST}:${PORT + 1}`);
console.log('Press Ctrl+C to quit.');
});
process.on('uncaughtException', err => {
console.log(`Uncaught Exception: ${err.message}`);
debug && console.log(err.stack);
// process.exit(1);
});
module.exports = {uapp, server};
}

View file

@ -0,0 +1,371 @@
const _io = require('socket.io');
const express = require('express');
const uaParser = require('ua-parser-js');
const geoip2Reader = require('@maxmind/geoip2-node').Reader;
const {extractPeerId} = require('./peerjs-server');
const {createAdapter} = require("@socket.io/redis-adapter");
const {createClient} = require("redis");
var wsRouter = express.Router();
const UPDATE_EVENT = "UPDATE_SESSION";
const IDENTITIES = {agent: 'agent', session: 'session'};
const NEW_AGENT = "NEW_AGENT";
const NO_AGENTS = "NO_AGENT";
const AGENT_DISCONNECT = "AGENT_DISCONNECTED";
const AGENTS_CONNECTED = "AGENTS_CONNECTED";
const NO_SESSIONS = "SESSION_DISCONNECTED";
const SESSION_ALREADY_CONNECTED = "SESSION_ALREADY_CONNECTED";
// const wsReconnectionTimeout = process.env.wsReconnectionTimeout | 10 * 1000;
let io;
const debug = process.env.debug === "1" || false;
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
const pubClient = createClient({url: REDIS_URL});
const subClient = pubClient.duplicate();
const uniqueSessions = function (data) {
let resArr = [];
let resArrIDS = [];
for (let item of data) {
if (resArrIDS.indexOf(item.sessionID) < 0) {
resArr.push(item);
resArrIDS.push(item.sessionID);
}
}
return resArr;
}
const socketsList = async function (req, res) {
debug && console.log("[WS]looking for all available sessions");
let liveSessions = {};
let rooms = await io.of('/').adapter.allRooms();
for (let peerId of rooms) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey !== undefined) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(sessionId);
}
}
let result = {"data": liveSessions};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-list`, socketsList);
const socketsListByProject = async function (req, res) {
if (process.env.uws === "true") {
req.params = {projectKey: req.getParameter(0)};
}
debug && console.log(`[WS]looking for available sessions for ${req.params.projectKey}`);
let liveSessions = {};
let rooms = await io.of('/').adapter.allRooms();
for (let peerId of rooms) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey === req.params.projectKey) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(sessionId);
}
}
let result = {"data": liveSessions[req.params.projectKey] || []};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-list/:projectKey`, socketsListByProject);
const socketsLive = async function (req, res) {
debug && console.log("[WS]looking for all available LIVE sessions");
let liveSessions = {};
let rooms = await io.of('/').adapter.allRooms();
for (let peerId of rooms) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey !== undefined) {
let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
}
}
liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey]);
}
}
let result = {"data": liveSessions};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-live`, socketsLive);
const socketsLiveByProject = async function (req, res) {
if (process.env.uws === "true") {
req.params = {projectKey: req.getParameter(0)};
}
debug && console.log(`[WS]looking for available LIVE sessions for ${req.params.projectKey}`);
let liveSessions = {};
let rooms = await io.of('/').adapter.allRooms();
for (let peerId of rooms) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey === req.params.projectKey) {
let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
}
}
liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey]);
}
}
let result = {"data": liveSessions[req.params.projectKey] || []};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-live/:projectKey`, socketsLiveByProject);
const findSessionSocketId = async (io, peerId) => {
const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
return item.id;
}
}
return null;
};
async function sessions_agents_count(io, socket) {
let c_sessions = 0, c_agents = 0;
let rooms = await io.of('/').adapter.allRooms();
if (rooms.has(socket.peerId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
c_sessions++;
} else {
c_agents++;
}
}
} else {
c_agents = -1;
c_sessions = -1;
}
return {c_sessions, c_agents};
}
async function get_all_agents_ids(io, socket) {
let agents = [];
let rooms = await io.of('/').adapter.allRooms();
if (rooms.has(socket.peerId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.agent) {
agents.push(item.id);
}
}
}
return agents;
}
function extractSessionInfo(socket) {
if (socket.handshake.query.sessionInfo !== undefined) {
debug && console.log("received headers");
debug && console.log(socket.handshake.headers);
socket.handshake.query.sessionInfo = JSON.parse(socket.handshake.query.sessionInfo);
let ua = uaParser(socket.handshake.headers['user-agent']);
socket.handshake.query.sessionInfo.userOs = ua.os.name || null;
socket.handshake.query.sessionInfo.userBrowser = ua.browser.name || null;
socket.handshake.query.sessionInfo.userBrowserVersion = ua.browser.version || null;
socket.handshake.query.sessionInfo.userDevice = ua.device.model || null;
socket.handshake.query.sessionInfo.userDeviceType = ua.device.type || 'desktop';
socket.handshake.query.sessionInfo.userCountry = null;
const options = {
// you can use options like `cache` or `watchForUpdates`
};
// console.log("Looking for MMDB file in " + process.env.MAXMINDDB_FILE);
geoip2Reader.open(process.env.MAXMINDDB_FILE, options)
.then(reader => {
debug && console.log("looking for location of ");
debug && console.log(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
let country = reader.country(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
socket.handshake.query.sessionInfo.userCountry = country.country.isoCode;
})
.catch(error => {
console.error(error);
});
}
}
module.exports = {
wsRouter,
start: (server) => {
if (process.env.uws !== "true") {
io = _io(server, {
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
cors: {
origin: "*",
methods: ["GET", "POST", "PUT"]
},
path: '/socket'
});
} else {
io = new _io.Server({
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
cors: {
origin: "*",
methods: ["GET", "POST", "PUT"]
},
path: '/socket',
// transports: ['websocket'],
// upgrade: false
});
io.attachApp(server);
}
io.on('connection', async (socket) => {
debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
socket.peerId = socket.handshake.query.peerId;
socket.identity = socket.handshake.query.identity;
let {projectKey, sessionId} = extractPeerId(socket.peerId);
socket.sessionId = sessionId;
socket.projectKey = projectKey;
socket.lastMessageReceivedAt = Date.now();
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (socket.identity === IDENTITIES.session) {
if (c_sessions > 0) {
debug && console.log(`session already connected, refusing new connexion`);
io.to(socket.id).emit(SESSION_ALREADY_CONNECTED);
return socket.disconnect();
}
extractSessionInfo(socket);
if (c_agents > 0) {
debug && console.log(`notifying new session about agent-existence`);
let agents_ids = await get_all_agents_ids(io, socket);
io.to(socket.id).emit(AGENTS_CONNECTED, agents_ids);
}
} else if (c_sessions <= 0) {
debug && console.log(`notifying new agent about no SESSIONS`);
io.to(socket.id).emit(NO_SESSIONS);
}
await io.of('/').adapter.remoteJoin(socket.id, socket.peerId);
let rooms = await io.of('/').adapter.allRooms();
if (rooms.has(socket.peerId)) {
let connectedSockets = await io.in(socket.peerId).fetchSockets();
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${connectedSockets.length}`);
}
if (socket.identity === IDENTITIES.agent) {
if (socket.handshake.query.agentInfo !== undefined) {
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo);
}
socket.to(socket.peerId).emit(NEW_AGENT, socket.id, socket.handshake.query.agentInfo);
}
socket.on('disconnect', async () => {
debug && console.log(`${socket.id} disconnected from ${socket.peerId}`);
if (socket.identity === IDENTITIES.agent) {
socket.to(socket.peerId).emit(AGENT_DISCONNECT, socket.id);
}
debug && console.log("checking for number of connected agents and sessions");
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (c_sessions === -1 && c_agents === -1) {
debug && console.log(`room not found: ${socket.peerId}`);
}
if (c_sessions === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`);
socket.to(socket.peerId).emit(NO_SESSIONS);
}
if (c_agents === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`);
socket.to(socket.peerId).emit(NO_AGENTS);
}
});
socket.on(UPDATE_EVENT, async (...args) => {
debug && console.log(`${socket.id} sent update event.`);
if (socket.identity !== IDENTITIES.session) {
debug && console.log('Ignoring update event.');
return
}
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]};
socket.to(socket.peerId).emit(UPDATE_EVENT, args[0]);
});
socket.onAny(async (eventName, ...args) => {
socket.lastMessageReceivedAt = Date.now();
if (socket.identity === IDENTITIES.session) {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`);
socket.to(socket.peerId).emit(eventName, args[0]);
} else {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}`);
let socketId = await findSessionSocketId(io, socket.peerId);
if (socketId === null) {
debug && console.log(`session not found for:${socket.peerId}`);
io.to(socket.id).emit(NO_SESSIONS);
} else {
debug && console.log("message sent");
io.to(socketId).emit(eventName, socket.id, args[0]);
}
}
});
});
console.log("WS server started")
setInterval(async (io) => {
try {
let rooms = await io.of('/').adapter.allRooms();
let validRooms = [];
console.log(` ====== Rooms: ${rooms.size} ====== `);
const arr = Array.from(rooms)
// const filtered = arr.filter(room => !room[1].has(room[0]))
for (let i of rooms) {
let {projectKey, sessionId} = extractPeerId(i);
if (projectKey !== undefined && sessionId !== undefined) {
validRooms.push(i);
}
}
console.log(` ====== Valid Rooms: ${validRooms.length} ====== `);
if (debug) {
for (let item of validRooms) {
let connectedSockets = await io.in(item).fetchSockets();
console.log(`Room: ${item} connected: ${connectedSockets.length}`)
}
}
} catch (e) {
console.error(e);
}
}, 20000, io);
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
console.log("> redis connected.");
// io.listen(3000);
});
},
handlers: {
socketsList,
socketsListByProject,
socketsLive,
socketsLiveByProject
}
};

View file

@ -0,0 +1,334 @@
const _io = require('socket.io');
const express = require('express');
const uaParser = require('ua-parser-js');
const geoip2Reader = require('@maxmind/geoip2-node').Reader;
var {extractPeerId} = require('./peerjs-server');
var wsRouter = express.Router();
const UPDATE_EVENT = "UPDATE_SESSION";
const IDENTITIES = {agent: 'agent', session: 'session'};
const NEW_AGENT = "NEW_AGENT";
const NO_AGENTS = "NO_AGENT";
const AGENT_DISCONNECT = "AGENT_DISCONNECTED";
const AGENTS_CONNECTED = "AGENTS_CONNECTED";
const NO_SESSIONS = "SESSION_DISCONNECTED";
const SESSION_ALREADY_CONNECTED = "SESSION_ALREADY_CONNECTED";
// const wsReconnectionTimeout = process.env.wsReconnectionTimeout | 10 * 1000;
let io;
let debug = process.env.debug === "1" || false;
const socketsList = function (req, res) {
debug && console.log("[WS]looking for all available sessions");
let liveSessions = {};
for (let peerId of io.sockets.adapter.rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey !== undefined) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(sessionId);
}
}
let result = {"data": liveSessions};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-list`, socketsList);
const socketsListByProject = function (req, res) {
if (process.env.uws === "true") {
req.params = {projectKey: req.getParameter(0)};
}
debug && console.log(`[WS]looking for available sessions for ${req.params.projectKey}`);
let liveSessions = {};
for (let peerId of io.sockets.adapter.rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey === req.params.projectKey) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(sessionId);
}
}
let result = {"data": liveSessions[req.params.projectKey] || []};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify());
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-list/:projectKey`, socketsListByProject);
const socketsLive = async function (req, res) {
debug && console.log("[WS]looking for all available LIVE sessions");
let liveSessions = {};
for (let peerId of io.sockets.adapter.rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey !== undefined) {
let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
}
}
}
}
let result = {"data": liveSessions};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-live`, socketsLive);
const socketsLiveByProject = async function (req, res) {
if (process.env.uws === "true") {
req.params = {projectKey: req.getParameter(0)};
}
debug && console.log(`[WS]looking for available LIVE sessions for ${req.params.projectKey}`);
let liveSessions = {};
for (let peerId of io.sockets.adapter.rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey === req.params.projectKey) {
let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || [];
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
}
}
}
}
let result = {"data": liveSessions[req.params.projectKey] || []};
if (process.env.uws !== "true") {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
}
}
wsRouter.get(`/${process.env.S3_KEY}/sockets-live/:projectKey`, socketsLiveByProject);
const findSessionSocketId = async (io, peerId) => {
const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
return item.id;
}
}
return null;
};
async function sessions_agents_count(io, socket) {
let c_sessions = 0, c_agents = 0;
if (io.sockets.adapter.rooms.get(socket.peerId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) {
c_sessions++;
} else {
c_agents++;
}
}
} else {
c_agents = -1;
c_sessions = -1;
}
return {c_sessions, c_agents};
}
async function get_all_agents_ids(io, socket) {
let agents = [];
if (io.sockets.adapter.rooms.get(socket.peerId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets();
for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.agent) {
agents.push(item.id);
}
}
}
return agents;
}
function extractSessionInfo(socket) {
if (socket.handshake.query.sessionInfo !== undefined) {
debug && console.log("received headers");
debug && console.log(socket.handshake.headers);
socket.handshake.query.sessionInfo = JSON.parse(socket.handshake.query.sessionInfo);
let ua = uaParser(socket.handshake.headers['user-agent']);
socket.handshake.query.sessionInfo.userOs = ua.os.name || null;
socket.handshake.query.sessionInfo.userBrowser = ua.browser.name || null;
socket.handshake.query.sessionInfo.userBrowserVersion = ua.browser.version || null;
socket.handshake.query.sessionInfo.userDevice = ua.device.model || null;
socket.handshake.query.sessionInfo.userDeviceType = ua.device.type || 'desktop';
socket.handshake.query.sessionInfo.userCountry = null;
const options = {
// you can use options like `cache` or `watchForUpdates`
};
// console.log("Looking for MMDB file in " + process.env.MAXMINDDB_FILE);
geoip2Reader.open(process.env.MAXMINDDB_FILE, options)
.then(reader => {
debug && console.log("looking for location of ");
debug && console.log(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
let country = reader.country(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
socket.handshake.query.sessionInfo.userCountry = country.country.isoCode;
})
.catch(error => {
console.error(error);
});
}
}
module.exports = {
wsRouter,
start: (server) => {
if (process.env.uws !== "true") {
io = _io(server, {
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
cors: {
origin: "*",
methods: ["GET", "POST", "PUT"]
},
path: '/socket'
});
} else {
io = new _io.Server({
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
cors: {
origin: "*",
methods: ["GET", "POST", "PUT"]
},
path: '/socket',
// transports: ['websocket'],
// upgrade: false
});
io.attachApp(server);
}
io.on('connection', async (socket) => {
debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
socket.peerId = socket.handshake.query.peerId;
socket.identity = socket.handshake.query.identity;
const {projectKey, sessionId} = extractPeerId(socket.peerId);
socket.sessionId = sessionId;
socket.projectKey = projectKey;
socket.lastMessageReceivedAt = Date.now();
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (socket.identity === IDENTITIES.session) {
if (c_sessions > 0) {
debug && console.log(`session already connected, refusing new connexion`);
io.to(socket.id).emit(SESSION_ALREADY_CONNECTED);
return socket.disconnect();
}
extractSessionInfo(socket);
if (c_agents > 0) {
debug && console.log(`notifying new session about agent-existence`);
let agents_ids = await get_all_agents_ids(io, socket);
io.to(socket.id).emit(AGENTS_CONNECTED, agents_ids);
}
} else if (c_sessions <= 0) {
debug && console.log(`notifying new agent about no SESSIONS`);
io.to(socket.id).emit(NO_SESSIONS);
}
socket.join(socket.peerId);
if (io.sockets.adapter.rooms.get(socket.peerId)) {
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${io.sockets.adapter.rooms.get(socket.peerId).size}`);
}
if (socket.identity === IDENTITIES.agent) {
if (socket.handshake.query.agentInfo !== undefined) {
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo);
}
socket.to(socket.peerId).emit(NEW_AGENT, socket.id, socket.handshake.query.agentInfo);
}
socket.on('disconnect', async () => {
debug && console.log(`${socket.id} disconnected from ${socket.peerId}`);
if (socket.identity === IDENTITIES.agent) {
socket.to(socket.peerId).emit(AGENT_DISCONNECT, socket.id);
}
debug && console.log("checking for number of connected agents and sessions");
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (c_sessions === -1 && c_agents === -1) {
debug && console.log(`room not found: ${socket.peerId}`);
}
if (c_sessions === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`);
socket.to(socket.peerId).emit(NO_SESSIONS);
}
if (c_agents === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`);
socket.to(socket.peerId).emit(NO_AGENTS);
}
});
socket.on(UPDATE_EVENT, async (...args) => {
debug && console.log(`${socket.id} sent update event.`);
if (socket.identity !== IDENTITIES.session) {
debug && console.log('Ignoring update event.');
return
}
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]};
socket.to(socket.peerId).emit(UPDATE_EVENT, args[0]);
});
socket.onAny(async (eventName, ...args) => {
socket.lastMessageReceivedAt = Date.now();
if (socket.identity === IDENTITIES.session) {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}, members: ${io.sockets.adapter.rooms.get(socket.peerId).size}`);
socket.to(socket.peerId).emit(eventName, args[0]);
} else {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}, members:${io.sockets.adapter.rooms.get(socket.peerId).size}`);
let socketId = await findSessionSocketId(io, socket.peerId);
if (socketId === null) {
debug && console.log(`session not found for:${socket.peerId}`);
io.to(socket.id).emit(NO_SESSIONS);
} else {
debug && console.log("message sent");
io.to(socketId).emit(eventName, socket.id, args[0]);
}
}
});
});
console.log("WS server started")
setInterval((io) => {
try {
let count = 0;
console.log(` ====== Rooms: ${io.sockets.adapter.rooms.size} ====== `);
const arr = Array.from(io.sockets.adapter.rooms)
const filtered = arr.filter(room => !room[1].has(room[0]))
for (let i of filtered) {
let {projectKey, sessionId} = extractPeerId(i[0]);
if (projectKey !== null && sessionId !== null) {
count++;
}
}
console.log(` ====== Valid Rooms: ${count} ====== `);
if (debug) {
for (let item of filtered) {
console.log(`Room: ${item[0]} connected: ${item[1].size}`)
}
}
} catch (e) {
console.error(e);
}
}, 20000, io);
},
handlers: {
socketsList,
socketsListByProject,
socketsLive,
socketsLiveByProject
}
};

View file

@ -21,7 +21,7 @@ const AssistTabs = (props: Props) => {
<Avatar iconSize="20" width="30px" height="30px" seed={ props.userNumericHash } />
<div className="ml-2 font-medium">
<TextEllipsis maxWidth={120} inverted popupProps={{ inverted: true, size: 'tiny' }}>
{props.userId}'s asdasd asdasdasdasd
{props.userId}'s
</TextEllipsis>
</div>
</div>
@ -35,7 +35,7 @@ const AssistTabs = (props: Props) => {
)}
</div>
<SlideModal
title={ <div>Live Sessions by {props.userId}</div> }
title={ <div>{props.userId}'s <span className="color-gray-medium">Live Sessions</span> </div> }
isDisplayed={ showMenu }
content={ showMenu && <SessionList /> }
onClose={ () => setShowMenu(false) }

View file

@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { fetchLiveList } from 'Duck/sessions';
import { Loader, NoContent } from 'UI';
import { Loader, NoContent, Label } from 'UI';
import SessionItem from 'Shared/SessionItem';
interface Props {
@ -22,7 +22,17 @@ function SessionList(props: Props) {
title="No live sessions."
>
<div className="p-4">
{ props.list.map(session => <SessionItem key={ session.sessionId } session={ session } />) }
{ props.list.map(session => (
<div className="mb-6">
{session.pageTitle && session.pageTitle !== '' && (
<div className="flex items-center mb-2">
<Label size="small" className="p-1"><span className="color-gray-medium">TAB</span></Label>
<span className="ml-2 font-medium">{session.pageTitle}</span>
</div>
)}
<SessionItem key={ session.sessionId } session={ session } showActive={true} />
</div>
)) }
</div>
</NoContent>
</Loader>

View file

@ -240,11 +240,10 @@ export default class Dashboard extends React.PureComponent {
Custom Metrics are not supported for comparison.
</div>
)}
{/* <CustomMetrics /> */}
</div>
}
>
<div className={cn("gap-4 grid grid-cols-2")} ref={this.list[CUSTOM_METRICS]}>
<div className={cn("")} ref={this.list[CUSTOM_METRICS]}>
<CustomMetricsWidgets onClickEdit={(e) => null}/>
</div>
</WidgetSection>

View file

@ -33,9 +33,6 @@ function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
<div className={stl.divider} />
<div className="my-3">
<CustomMetrics />
<div className="color-gray-medium mt-2">
Be proactive by monitoring the metrics you care about the most.
</div>
</div>
</>
);

View file

@ -0,0 +1,59 @@
import React from 'react'
import { Styles } from '../../common';
import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Area, Tooltip } from 'recharts';
import { LineChart, Line, Legend } from 'recharts';
interface Props {
data: any;
params: any;
seriesMap: any;
colors: any;
onClick?: (event, index) => void;
}
function CustomMetriLineChart(props: Props) {
const { data, params, seriesMap, colors, onClick = () => null } = props;
return (
<ResponsiveContainer height={ 240 } width="100%">
<LineChart
data={ data }
margin={Styles.chartMargins}
// syncId={ showSync ? "domainsErrors_4xx" : undefined }
onClick={onClick}
isAnimationActive={ false }
>
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={params.density/7}
/>
<YAxis
{...Styles.yaxis}
allowDecimals={false}
label={{
...Styles.axisLabelLeft,
value: "Number of Sessions"
}}
/>
<Legend />
<Tooltip {...Styles.tooltip} />
{ seriesMap.map((key, index) => (
<Line
key={key}
name={key}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={ 1 }
strokeWidth={ 2 }
strokeOpacity={ 0.6 }
// fill="url(#colorCount)"
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
)
}
export default CustomMetriLineChart

View file

@ -0,0 +1 @@
export { default } from './CustomMetriLineChart';

View file

@ -0,0 +1,20 @@
import React from 'react'
interface Props {
data: any;
params: any;
colors: any;
onClick?: (event, index) => void;
}
function CustomMetriPercentage(props: Props) {
const { data = {} } = props;
return (
<div className="flex flex-col items-center justify-center" style={{ height: '240px'}}>
<div className="text-6xl">{data.count}</div>
<div className="text-lg mt-6">{`${data.previousCount} ( ${data.countProgress}% )`}</div>
<div className="color-gray-medium">from previous period.</div>
</div>
)
}
export default CustomMetriPercentage;

View file

@ -0,0 +1 @@
export { default } from './CustomMetricPercentage';

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -0,0 +1,156 @@
import React from 'react'
import { ResponsiveContainer, Tooltip } from 'recharts';
import { PieChart, Pie, Cell } from 'recharts';
import { Styles } from '../../common';
import { NoContent } from 'UI';
import { filtersMap } from 'Types/filter/newFilter';
interface Props {
metric: any,
data: any;
params: any;
// seriesMap: any;
colors: any;
onClick?: (filters) => void;
}
function CustomMetricPieChart(props: Props) {
const { metric, data = { values: [] }, onClick = () => null } = props;
const onClickHandler = (event) => {
if (event && !event.payload.group) {
const filters = Array<any>();
let filter = { ...filtersMap[metric.metricOf] }
filter.value = [event.payload.name]
filter.type = filter.key
delete filter.key
delete filter.operatorOptions
delete filter.category
delete filter.icon
delete filter.label
delete filter.options
filters.push(filter);
onClick(filters);
}
}
return (
<div>
<NoContent size="small" show={data.values && data.values.length === 0} >
<ResponsiveContainer height={ 220 } width="100%">
<PieChart>
<Pie
isAnimationActive={ false }
data={data.values}
dataKey="sessionCount"
nameKey="name"
cx="50%"
cy="50%"
// innerRadius={40}
outerRadius={70}
// fill={colors[0]}
activeIndex={1}
onClick={onClickHandler}
labelLine={({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
value,
index
}) => {
const RADIAN = Math.PI / 180;
let radius1 = 15 + innerRadius + (outerRadius - innerRadius);
let radius2 = innerRadius + (outerRadius - innerRadius);
let x2 = cx + radius1 * Math.cos(-midAngle * RADIAN);
let y2 = cy + radius1 * Math.sin(-midAngle * RADIAN);
let x1 = cx + radius2 * Math.cos(-midAngle * RADIAN);
let y1 = cy + radius2 * Math.sin(-midAngle * RADIAN);
const percentage = value * 100 / data.values.reduce((a, b) => a + b.sessionCount, 0);
if (percentage<3){
return null;
}
return(
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3EAAAF" strokeWidth={1} />
)
}}
label={({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
value,
index
}) => {
const RADIAN = Math.PI / 180;
let radius = 20 + innerRadius + (outerRadius - innerRadius);
let x = cx + radius * Math.cos(-midAngle * RADIAN);
let y = cy + radius * Math.sin(-midAngle * RADIAN);
const percentage = (value / data.values.reduce((a, b) => a + b.sessionCount, 0)) * 100;
let name = data.values[index].name || 'Unidentified';
name = name.length > 20 ? name.substring(0, 20) + '...' : name;
if (percentage<3){
return null;
}
return (
<text
x={x}
y={y}
fontWeight="400"
fontSize="12px"
// fontFamily="'Source Sans Pro', 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'"
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
fill='#666'
>
{name || 'Unidentified'} {value}
</text>
);
}}
// label={({
// cx,
// cy,
// midAngle,
// innerRadius,
// outerRadius,
// value,
// index
// }) => {
// const RADIAN = Math.PI / 180;
// const radius = 30 + innerRadius + (outerRadius - innerRadius);
// const x = cx + radius * Math.cos(-midAngle * RADIAN);
// const y = cy + radius * Math.sin(-midAngle * RADIAN);
// return (
// <text
// x={x}
// y={y}
// fill="#3EAAAF"
// textAnchor={x > cx ? "start" : "end"}
// dominantBaseline="top"
// fontSize={10}
// >
// {data.values[index].name} ({value})
// </text>
// );
// }}
>
{data.values.map((entry, index) => (
<Cell key={`cell-${index}`} fill={Styles.colorsPie[index % Styles.colorsPie.length]} />
))}
</Pie>
<Tooltip {...Styles.tooltip} />
</PieChart>
</ResponsiveContainer>
<div className="text-sm color-gray-medium">Top 5 </div>
</NoContent>
</div>
)
}
export default CustomMetricPieChart;

View file

@ -0,0 +1 @@
export { default } from './CustomMetricPieChart';

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -0,0 +1,64 @@
import React from 'react'
import { Table } from '../../common';
import { List } from 'immutable';
import { filtersMap } from 'Types/filter/newFilter';
import { NoContent } from 'UI';
import { tableColumnName } from 'App/constants/filterOptions';
const getColumns = (metric) => {
return [
{
key: 'name',
title: tableColumnName[metric.metricOf],
toText: name => name || 'Unidentified',
width: '70%',
},
{
key: 'sessionCount',
title: 'Sessions',
toText: sessions => sessions,
width: '30%',
},
]
}
interface Props {
metric?: any,
data: any;
onClick?: (filters) => void;
}
function CustomMetriTable(props: Props) {
const { metric = {}, data = { values: [] }, onClick = () => null } = props;
const rows = List(data.values);
const onClickHandler = (event, data) => {
const filters = Array<any>();
let filter = { ...filtersMap[metric.metricOf] }
filter.value = [data.name]
filter.type = filter.key
delete filter.key
delete filter.operatorOptions
delete filter.category
delete filter.icon
delete filter.label
delete filter.options
filters.push(filter);
onClick(filters);
}
return (
<div className="" style={{ height: '240px'}}>
<NoContent show={data.values && data.values.length === 0} size="small">
<Table
small
cols={ getColumns(metric) }
rows={ rows }
rowClass="group"
onRowClick={ onClickHandler }
/>
</NoContent>
</div>
)
}
export default CustomMetriTable;

View file

@ -0,0 +1 @@
export { default } from './CustomMetricTable';

View file

@ -2,22 +2,25 @@ import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { Loader, NoContent, Icon, Popup } from 'UI';
import { Styles } from '../../common';
import { ResponsiveContainer, AreaChart, XAxis, YAxis, CartesianGrid, Area, Tooltip } from 'recharts';
import { LineChart, Line, Legend } from 'recharts';
import { ResponsiveContainer } from 'recharts';
import { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
import stl from './CustomMetricWidget.css';
import { getChartFormatter, getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
import { init, edit, remove, setAlertMetricId, setActiveWidget, updateActiveState } from 'Duck/customMetrics';
import APIClient from 'App/api_client';
import { setShowAlerts } from 'Duck/dashboard';
import CustomMetriLineChart from '../CustomMetriLineChart';
import CustomMetricPieChart from '../CustomMetricPieChart';
import CustomMetricPercentage from '../CustomMetricPercentage';
import CustomMetricTable from '../CustomMetricTable';
const customParams = rangeName => {
const params = { density: 70 }
if (rangeName === LAST_24_HOURS) params.density = 70
if (rangeName === LAST_30_MINUTES) params.density = 70
if (rangeName === YESTERDAY) params.density = 70
if (rangeName === LAST_7_DAYS) params.density = 70
// if (rangeName === LAST_24_HOURS) params.density = 70
// if (rangeName === LAST_30_MINUTES) params.density = 70
// if (rangeName === YESTERDAY) params.density = 70
// if (rangeName === LAST_7_DAYS) params.density = 70
return params
}
@ -47,11 +50,14 @@ function CustomMetricWidget(props: Props) {
const colors = Styles.customMetricColors;
const params = customParams(period.rangeName)
const gradientDef = Styles.gradientDef();
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart', startDate: period.start, endDate: period.end }
const isLineChart = metric.viewType === 'lineChart';
const isProgress = metric.viewType === 'progress';
const isTable = metric.viewType === 'table';
const isPieChart = metric.viewType === 'pieChart';
useEffect(() => {
new APIClient()['post']('/custom_metrics/chart', { ...metricParams, q: metric.name })
new APIClient()['post'](`/custom_metrics/${metricParams.metricId}/chart`, { ...metricParams, q: metric.name })
.then(response => response.json())
.then(({ errors, data }) => {
if (errors) {
@ -74,12 +80,33 @@ function CustomMetricWidget(props: Props) {
}).finally(() => setLoading(false));
}, [period])
const clickHandlerTable = (filters) => {
const activeWidget = {
widget: metric,
period: period,
...period.toTimestamps(),
filters,
}
props.setActiveWidget(activeWidget);
}
const clickHandler = (event, index) => {
if (event) {
const payload = event.activePayload[0].payload;
const timestamp = payload.timestamp;
const { startTimestamp, endTimestamp } = getStartAndEndTimestampsByDensity(timestamp, period.start, period.end, params.density);
props.setActiveWidget({ widget: metric, startTimestamp, endTimestamp, timestamp: payload.timestamp, index })
const periodTimestamps = metric.metricType === 'timeseries' ?
getStartAndEndTimestampsByDensity(timestamp, period.start, period.end, params.density) :
period.toTimestamps();
const activeWidget = {
widget: metric,
period: period,
...periodTimestamps,
timestamp: payload.timestamp,
index,
}
props.setActiveWidget(activeWidget);
}
}
@ -89,64 +116,59 @@ function CustomMetricWidget(props: Props) {
return (
<div className={stl.wrapper}>
<div className="flex items-center mb-10 p-2">
<div className="flex items-center p-2">
<div className="font-medium">{metric.name}</div>
<div className="ml-auto flex items-center">
<WidgetIcon className="cursor-pointer mr-6" icon="bell-plus" tooltip="Set Alert" onClick={props.onAlertClick} />
{!isTable && !isPieChart && <WidgetIcon className="cursor-pointer mr-6" icon="bell-plus" tooltip="Set Alert" onClick={props.onAlertClick} /> }
<WidgetIcon className="cursor-pointer mr-6" icon="pencil" tooltip="Edit Metric" onClick={() => props.init(metric)} />
<WidgetIcon className="cursor-pointer" icon="close" tooltip="Hide Metric" onClick={() => updateActiveState(metric.metricId, false)} />
</div>
</div>
<div>
<div className="px-3">
<Loader loading={ loading } size="small">
<NoContent
size="small"
show={ data.length === 0 }
>
<ResponsiveContainer height={ 240 } width="100%">
<LineChart
data={ data }
margin={Styles.chartMargins}
syncId={ showSync ? "domainsErrors_4xx" : undefined }
onClick={clickHandler}
>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={colors[4]} stopOpacity={ 0.9 } />
<stop offset="95%" stopColor={colors[4]} stopOpacity={ 0.2 } />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={params.density/7}
/>
<YAxis
{...Styles.yaxis}
allowDecimals={false}
label={{
...Styles.axisLabelLeft,
value: "Number of Sessions"
}}
/>
<Legend />
<Tooltip {...Styles.tooltip} />
{ seriesMap.map((key, index) => (
<Line
key={key}
name={key}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={ 1 }
strokeWidth={ 2 }
strokeOpacity={ 0.8 }
fill="url(#colorCount)"
dot={false}
<>
{isLineChart && (
<CustomMetriLineChart
data={ data }
params={ params }
seriesMap={ seriesMap }
colors={ colors }
onClick={ clickHandler }
/>
))}
</LineChart>
)}
{isPieChart && (
<CustomMetricPieChart
metric={metric}
data={ data[0] }
params={ params }
colors={ colors }
onClick={ clickHandlerTable }
/>
)}
{isProgress && (
<CustomMetricPercentage
data={ data[0] }
params={ params }
colors={ colors }
onClick={ clickHandler }
/>
)}
{isTable && (
<CustomMetricTable
metric={ metric }
data={ data[0] }
onClick={ clickHandlerTable }
/>
)}
</>
</ResponsiveContainer>
</NoContent>
</Loader>

View file

@ -1,6 +1,14 @@
.wrapper {
background-color: white;
background-color: $gray-light;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
padding: 20px;
}
.innerWapper {
border-radius: 3px;
width: 70%;
margin: 0 auto;
background-color: white;
min-height: 220px;
}

View file

@ -1,16 +1,20 @@
import React, { useEffect, useState, useRef } from 'react';
import { connect } from 'react-redux';
import { Loader, NoContent, Icon } from 'UI';
import { Loader, NoContent, SegmentSelection, Icon } from 'UI';
import { Styles } from '../../common';
import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, Legend } from 'recharts';
// import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, Legend } from 'recharts';
import Period, { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
import stl from './CustomMetricWidgetPreview.css';
import { getChartFormatter } from 'Types/dashboard/helper';
import { remove } from 'Duck/customMetrics';
import DateRange from 'Shared/DateRange';
import { edit } from 'Duck/customMetrics';
import CustomMetriLineChart from '../CustomMetriLineChart';
import CustomMetricPercentage from '../CustomMetricPercentage';
import CustomMetricTable from '../CustomMetricTable';
import APIClient from 'App/api_client';
import CustomMetricPieChart from '../CustomMetricPieChart';
const customParams = rangeName => {
const params = { density: 70 }
@ -43,8 +47,9 @@ function CustomMetricWidget(props: Props) {
const params = customParams(period.rangeName)
const gradientDef = Styles.gradientDef();
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart' }
const prevMetricRef = useRef<any>();
const isTimeSeries = metric.metricType === 'timeseries';
const isTable = metric.metricType === 'table';
useEffect(() => {
// Check for title change
@ -83,11 +88,52 @@ function CustomMetricWidget(props: Props) {
props.edit({ ...changedDates, rangeName: changedDates.rangeValue });
}
const chagneViewType = (e, { name, value }) => {
props.edit({ [ name ]: value });
}
return (
<div className="mb-10">
<div className="flex items-center mb-4">
<div className="flex items-center">
<div className="mr-auto font-medium">Preview</div>
<div>
<div className="flex items-center">
{isTimeSeries && (
<>
<span className="color-gray-medium mr-2">Visualization</span>
<SegmentSelection
name="viewType"
className="my-3"
primary
icons={true}
onSelect={ chagneViewType }
value={{ value: metric.viewType }}
list={ [
{ value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' },
{ value: 'progress', name: 'Progress', icon: 'hash' },
]}
/>
</>
)}
{isTable && (
<>
<span className="mr-1 color-gray-medium">Visualization</span>
<SegmentSelection
name="viewType"
className="my-3"
primary={true}
icons={true}
onSelect={ chagneViewType }
value={{ value: metric.viewType }}
list={[
{ value: 'table', name: 'Table', icon: 'table' },
{ value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' },
]}
/>
</>
)}
<div className="mx-4" />
<span className="mr-1 color-gray-medium">Time Range</span>
<DateRange
rangeValue={metric.rangeName}
startDate={metric.startDate}
@ -99,50 +145,51 @@ function CustomMetricWidget(props: Props) {
</div>
</div>
<div className={stl.wrapper}>
<div>
<div className={stl.innerWapper}>
<Loader loading={ loading } size="small">
<NoContent
size="small"
show={ data.length === 0 }
>
<ResponsiveContainer height={ 240 } width="100%">
<LineChart
data={ data }
margin={Styles.chartMargins}
syncId={ showSync ? "domainsErrors_4xx" : undefined }
>
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={params.density/7}
/>
<YAxis
{...Styles.yaxis}
allowDecimals={false}
label={{
...Styles.axisLabelLeft,
value: "Number of Sessions"
}}
/>
<Legend />
<Tooltip {...Styles.tooltip} />
{ seriesMap.map((key, index) => (
<Line
key={key}
name={key}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={ 1 }
strokeWidth={ 2 }
strokeOpacity={ 0.6 }
// fill="url(#colorCount)"
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
<div className="p-4 font-medium">
{metric.name}
</div>
<div className="px-4 pb-4">
{ isTimeSeries && (
<>
{ metric.viewType === 'progress' && (
<CustomMetricPercentage
data={data[0]}
colors={colors}
params={params}
/>
)}
{ metric.viewType === 'lineChart' && (
<CustomMetriLineChart
data={data}
seriesMap={seriesMap}
colors={colors}
params={params}
/>
)}
</>
)}
{ isTable && (
<>
{ metric.viewType === 'table' ? (
<CustomMetricTable metric={metric} data={data[0]} />
) : (
<CustomMetricPieChart
metric={metric}
data={data[0]}
colors={colors}
params={params}
/>
)}
</>
)}
</div>
</NoContent>
</Loader>
</div>

View file

@ -5,6 +5,7 @@ import CustomMetricWidget from './CustomMetricWidget';
import AlertFormModal from 'App/components/Alerts/AlertFormModal';
import { init as initAlert } from 'Duck/alerts';
import LazyLoad from 'react-lazyload';
import CustomMetrics from 'App/components/shared/CustomMetrics';
interface Props {
fetchList: Function;
@ -15,6 +16,7 @@ interface Props {
function CustomMetricsWidgets(props: Props) {
const { list } = props;
const [activeMetricId, setActiveMetricId] = useState(null);
const activeList = list.filter(item => item.active);
useEffect(() => {
props.fetchList()
@ -22,19 +24,34 @@ function CustomMetricsWidgets(props: Props) {
return (
<>
{list.filter(item => item.active).map((item: any) => (
<LazyLoad>
<CustomMetricWidget
key={item.metricId}
metric={item}
onClickEdit={props.onClickEdit}
onAlertClick={(e) => {
setActiveMetricId(item.metricId)
props.initAlert({ query: { left: item.series.first().seriesId }})
}}
/>
</LazyLoad>
))}
<div className="gap-4 grid grid-cols-2">
{activeList.map((item: any) => (
<LazyLoad>
<CustomMetricWidget
key={item.metricId}
metric={item}
onClickEdit={props.onClickEdit}
onAlertClick={(e) => {
setActiveMetricId(item.metricId)
props.initAlert({ query: { left: item.series.first().seriesId }})
}}
/>
</LazyLoad>
))}
</div>
{list.size === 0 && (
<div className="flex items-center py-2">
<div className="mr-2 color-gray-medium">Be proactive by monitoring the metrics you care about the most.</div>
<CustomMetrics />
</div>
)}
{list.size > 0 && activeList && activeList.size === 0 && (
<div className="flex items-center py-2">
<div className="mr-2 color-gray-medium">It's blank here, add a metric to this section.</div>
</div>
)}
<AlertFormModal
showModal={!!activeMetricId}

View file

@ -5,6 +5,7 @@ const colorsx = ['#256669', '#38999e', '#3eaaaf', '#51b3b7', '#78c4c7', '#9fd5d7
const compareColors = ['#394EFF', '#4D5FFF', '#808DFF', '#B3BBFF', '#E5E8FF'];
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
const customMetricColors = ['#3EAAAF', '#394EFF', '#666666'];
const colorsPie = colors.concat(["#DDDDDD"]);
const countView = count => {
const isMoreThanK = count >= 1000;
@ -14,6 +15,7 @@ const countView = count => {
export default {
customMetricColors,
colors,
colorsPie,
colorsx,
compareColors,
compareColorsx,

View file

@ -16,7 +16,9 @@ export default class Table extends React.PureComponent {
rowProps,
rowClass = '',
small = false,
compare = false
compare = false,
maxHeight = 200,
onRowClick = null,
} = this.props;
const { showAll } = this.state;
@ -30,9 +32,13 @@ export default class Table extends React.PureComponent {
<div key={ key } style={ { width } } className={ stl.header }>{ title }</div>)
}
</div>
<div className={ cn(stl.content, "thin-scrollbar") }>
<div className={ cn(stl.content, "thin-scrollbar") } style={{ maxHeight: maxHeight + 'px'}}>
{ rows.take(showAll ? 10 : (small ? 3 : 5)).map(row => (
<div className={ cn(rowClass, stl.row, { [stl.small]: small}) } key={ row.key }>
<div
className={ cn(rowClass, stl.row, { [stl.small]: small, 'cursor-pointer' : !!onRowClick}) }
key={ row.key }
onClick={onRowClick ? (e) => onRowClick(e, row) : () => null}
>
{ cols.map(({ cellClass = '', className = '', Component, key, toText = t => t, width }) => (
<div className={ cn(stl.cell, cellClass) } style={{ width }} key={ key }> { Component
? <Component compare={compare} data={ row } { ...rowProps } />
@ -41,21 +47,20 @@ export default class Table extends React.PureComponent {
</div>
)) }
</div>
)) }
{ rows.size > (small ? 3 : 5) && !showAll &&
<div className="w-full flex justify-center mt-3">
)) }
</div>
{ rows.size > (small ? 3 : 5) && !showAll &&
<div className="w-full flex justify-center">
<Button
onClick={ this.onLoadMoreClick }
plain
small
className="text-center"
>
{ 'Load More' }
{ rows.size + ' More' }
</Button>
</div>
}
</div>
</div>
);
}

View file

@ -17,9 +17,11 @@ const inputModeOptions = [
const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
<script>
var initOpts = { projectKey: "PROJECT_KEY", ingestPoint: "https://${window.location.hostname}/ingest"};
var startOpts = { userID: "" };
(function(A,s,a,y,e,r){
r=window.OpenReplay=[s,r,e,[y-1]];
s=document.createElement('script');s.src=a;s.async=!A;
r=window.OpenReplay=[e,r,y,[s-1, e]];
s=document.createElement('script');s.src=A;s.async=!a;
document.getElementsByTagName('head')[0].appendChild(s);
r.start=function(v){r.push([0])};
r.stop=function(v){r.push([1])};
@ -30,8 +32,7 @@ const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
r.issue=function(k,p){r.push([6,k,p])};
r.isActive=function(){return false};
r.getSessionToken=function(){};
r.i="https://${window.location.hostname}/ingest";
})(0, "PROJECT_KEY", "//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",1,XXX);
})("//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",XXX,0,initOpts,startOpts);
</script>`;

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Form, SegmentSelection, Button, IconButton } from 'UI';
import { Form, Button, IconButton, HelpText } from 'UI';
import FilterSeries from '../FilterSeries';
import { connect } from 'react-redux';
import { edit as editMetric, save, addSeries, removeSeries, remove } from 'Duck/customMetrics';
@ -7,7 +7,9 @@ import CustomMetricWidgetPreview from 'App/components/Dashboard/Widgets/CustomMe
import { confirm } from 'UI/Confirmation';
import { toast } from 'react-toastify';
import cn from 'classnames';
import DropdownPlain from '../../DropdownPlain';
import { metricTypes, metricOf, issueOptions } from 'App/constants/filterOptions';
import { FilterKey } from 'Types/filter/filterType';
interface Props {
metric: any;
editMetric: (metric, shouldFetch?) => void;
@ -21,6 +23,13 @@ interface Props {
function CustomMetricForm(props: Props) {
const { metric, loading } = props;
// const metricOfOptions = metricOf.filter(i => i.key === metric.metricType);
const timeseriesOptions = metricOf.filter(i => i.type === 'timeseries');
const tableOptions = metricOf.filter(i => i.type === 'table');
const isTable = metric.metricType === 'table';
const isTimeSeries = metric.metricType === 'timeseries';
const _issueOptions = [{ text: 'All', value: 'all' }].concat(issueOptions);
const addSeries = () => {
props.addSeries();
@ -30,12 +39,33 @@ function CustomMetricForm(props: Props) {
props.removeSeries(index);
}
const write = ({ target: { value, name } }) => props.editMetric({ ...metric, [ name ]: value }, false);
const write = ({ target: { value, name } }) => props.editMetric({ [ name ]: value }, false);
const writeOption = (e, { value, name }) => {
props.editMetric({ [ name ]: value }, false);
const changeConditionTab = (e, { name, value }) => {
props.editMetric({[ 'viewType' ]: value });
if (name === 'metricValue') {
props.editMetric({ metricValue: [value] }, false);
}
if (name === 'metricOf') {
if (value === FilterKey.ISSUE) {
props.editMetric({ metricValue: ['all'] }, false);
}
}
if (name === 'metricType') {
if (value === 'timeseries') {
props.editMetric({ metricOf: timeseriesOptions[0].value, viewType: 'lineChart' }, false);
} else if (value === 'table') {
props.editMetric({ metricOf: tableOptions[0].value, viewType: 'table' }, false);
}
}
};
// const changeConditionTab = (e, { name, value }) => {
// props.editMetric({[ 'viewType' ]: value });
// };
const save = () => {
props.save(metric).then(() => {
toast.success(metric.exists() ? 'Updated succesfully.' : 'Created succesfully.');
@ -79,42 +109,92 @@ function CustomMetricForm(props: Props) {
<div className="form-group">
<label className="font-medium">Metric Type</label>
<div className="flex items-center">
<span className="bg-white p-1 px-2 border rounded" style={{ height: '30px'}}>Timeseries</span>
<span className="mx-2 color-gray-medium">of</span>
<div>
<SegmentSelection
primary
name="viewType"
small={true}
// className="my-3"
onSelect={ changeConditionTab }
value={{ value: metric.viewType }}
list={ [
{ name: 'Session Count', value: 'lineChart' },
{ name: 'Session Percentage', value: 'progress', disabled: true },
]}
/>
</div>
<DropdownPlain
name="metricType"
options={metricTypes}
value={ metric.metricType }
onChange={ writeOption }
/>
{metric.metricType === 'timeseries' && (
<>
<span className="mx-3">of</span>
<DropdownPlain
name="metricOf"
options={timeseriesOptions}
value={ metric.metricOf }
onChange={ writeOption }
/>
</>
)}
{metric.metricType === 'table' && (
<>
<span className="mx-3">of</span>
<DropdownPlain
name="metricOf"
options={tableOptions}
value={ metric.metricOf }
onChange={ writeOption }
/>
</>
)}
{metric.metricOf === FilterKey.ISSUE && (
<>
<span className="mx-3">issue type</span>
<DropdownPlain
name="metricValue"
options={_issueOptions}
value={ metric.metricValue[0] }
onChange={ writeOption }
/>
</>
)}
{metric.metricType === 'table' && (
<>
<span className="mx-3">showing</span>
<DropdownPlain
name="metricFormat"
options={[
{ value: 'sessionCount', text: 'Session Count' },
]}
value={ metric.metricFormat }
onChange={ writeOption }
/>
</>
)}
</div>
</div>
<div className="form-group">
<label className="font-medium">Chart Series</label>
{metric.series && metric.series.size > 0 && metric.series.map((series: any, index: number) => (
<label className="font-medium flex items-center">
{`${isTable ? 'Filter by' : 'Chart Series'}`}
{!isTable && <HelpText position="top left" text="Defines a series of data for the line in chart." className="pl-3" />}
</label>
{metric.series && metric.series.size > 0 && metric.series.take(isTable ? 1 : metric.series.size).map((series: any, index: number) => (
<div className="mb-2">
<FilterSeries
hideHeader={ isTable }
seriesIndex={index}
series={series}
onRemoveSeries={() => removeSeries(index)}
canDelete={metric.series.size > 1}
emptyMessage={isTable ?
'Filter data using any event or attribute. Use Add Step button below to do so.' :
'Add user event or filter to define the series by clicking Add Step.'
}
/>
</div>
))}
</div>
<div className={cn("flex justify-end -my-4", {'disabled' : metric.series.size > 2})}>
<IconButton hover type="button" onClick={addSeries} primaryText label="SERIES" icon="plus" />
</div>
{ isTimeSeries && (
<div className={cn("flex justify-end -my-4", {'disabled' : metric.series.size > 2})}>
<IconButton hover type="button" onClick={addSeries} primaryText label="SERIES" icon="plus" />
</div>
)}
<div className="my-8" />

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { IconButton } from 'UI';
import { connect } from 'react-redux';
import { edit, init } from 'Duck/customMetrics';

View file

@ -25,15 +25,20 @@ interface Props {
editSeriesFilterFilter: typeof editSeriesFilterFilter;
editSeriesFilter: typeof editSeriesFilter;
removeSeriesFilterFilter: typeof removeSeriesFilterFilter;
hideHeader?: boolean;
emptyMessage?: any;
}
function FilterSeries(props: Props) {
const { canDelete } = props;
const { canDelete, hideHeader = false, emptyMessage = 'Add user event or filter to define the series by clicking Add Step.' } = props;
const [expanded, setExpanded] = useState(true)
const { series, seriesIndex } = props;
const onAddFilter = (filter) => {
filter.value = [""]
if (filter.hasOwnProperty('filters')) {
filter.filters = filter.filters.map(i => ({ ...i, value: [""] }))
}
props.addSeriesFilterFilter(seriesIndex, filter);
}
@ -51,9 +56,9 @@ function FilterSeries(props: Props) {
return (
<div className="border rounded bg-white">
<div className="border-b px-5 h-12 flex items-center relative">
<div className={cn("border-b px-5 h-12 flex items-center relative", { 'hidden': hideHeader })}>
<div className="mr-auto">
<SeriesName name={series.name} onUpdate={(name) => props.updateSeries(seriesIndex, { name }) } />
<SeriesName seriesIndex={seriesIndex} name={series.name} onUpdate={(name) => props.updateSeries(seriesIndex, { name }) } />
</div>
<div className="flex items-center cursor-pointer" >
@ -78,10 +83,10 @@ function FilterSeries(props: Props) {
onChangeEventsOrder={onChangeEventsOrder}
/>
): (
<div className="color-gray-medium">Add user event or filter to define the series by clicking Add Step.</div>
<div className="color-gray-medium">{emptyMessage}</div>
)}
</div>
<div className="px-5 border-t h-12 flex items-center">
<div className="px-6 border-t h-12 flex items-center -mx-4">
<FilterSelection
filter={undefined}
onFilterClick={onAddFilter}

View file

@ -4,8 +4,10 @@ import { Icon } from 'UI';
interface Props {
name: string;
onUpdate: (name) => void;
seriesIndex?: number;
}
function SeriesName(props: Props) {
const { seriesIndex = 1 } = props;
const [editing, setEditing] = useState(false)
const [name, setName] = useState(props.name)
const ref = useRef<any>(null)
@ -36,7 +38,7 @@ function SeriesName(props: Props) {
<input
ref={ ref }
name="name"
className="fluid border-0 -mx-2 px-2"
className="fluid border-0 -mx-2 px-2 h-8"
value={name}
// readOnly={!editing}
onChange={write}
@ -44,7 +46,7 @@ function SeriesName(props: Props) {
onFocus={() => setEditing(true)}
/>
) : (
<div className="text-base">{name}</div>
<div className="text-base h-8 flex items-center border-transparent">{name.trim() === '' ? 'Seriess ' + (seriesIndex + 1) : name }</div>
)}
<div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div>

View file

@ -5,7 +5,6 @@ import stl from './SessionListModal.css';
import { connect } from 'react-redux';
import { fetchSessionList, setActiveWidget } from 'Duck/customMetrics';
import { DateTime } from 'luxon';
interface Props {
loading: boolean;
list: any;
@ -24,7 +23,8 @@ function SessionListModal(props: Props) {
props.fetchSessionList({
metricId: activeWidget.widget.metricId,
startDate: activeWidget.startTimestamp,
endDate: activeWidget.endTimestamp
endDate: activeWidget.endTimestamp,
filters: activeWidget.filters || [],
});
}, [activeWidget]);
@ -57,9 +57,9 @@ function SessionListModal(props: Props) {
const writeOption = (e, { name, value }) => setActiveSeries(value);
const filteredSessions = getListSessionsBySeries(activeSeries);
const startTime = DateTime.fromMillis(activeWidget.startTimestamp).toFormat('LLL dd, yyyy HH:mm a');
const endTime = DateTime.fromMillis(activeWidget.endTimestamp).toFormat('LLL dd, yyyy HH:mm a');
return (
<SlideModal
title={ activeWidget && (

View file

@ -1,5 +1,5 @@
.button {
padding: 0 8px;
padding: 0 4px;
border-radius: 3px;
color: $teal;
cursor: pointer;
@ -12,7 +12,7 @@
}
.dropdownTrigger {
padding: 4px 6px;
padding: 4px;
&:hover {
background-color: $gray-light;
}
@ -42,7 +42,7 @@
.dropdown {
display: flex !important;
padding: 4px 6px;
padding: 4px 4px;
border-radius: 3px;
color: $gray-darkest;
font-weight: 500;
@ -52,7 +52,7 @@
}
.dropdownTrigger {
padding: 4px 8px;
padding: 4px 4px;
border-radius: 3px;
&:hover {
background-color: $gray-light;

View file

@ -1,16 +1,18 @@
.dropdown {
display: flex !important;
padding: 4px 6px;
padding: 4px;
border-radius: 3px;
color: $gray-darkest;
font-weight: 500;
background-color: white;
border: solid thin $gray-light;
&:hover {
background-color: $gray-light;
}
}
.dropdownTrigger {
padding: 4px 8px;
padding: 4px;
border-radius: 3px;
&:hover {
background-color: $gray-light;

View file

@ -3,25 +3,30 @@ import stl from './DropdownPlain.css';
import { Dropdown, Icon } from 'UI';
interface Props {
name?: string;
options: any[];
onChange: (e, { name, value }) => void;
icon?: string;
direction?: string;
value: any;
multiple?: boolean;
}
export default function DropdownPlain(props: Props) {
const { value, options, icon = "chevron-down", direction = "left" } = props;
const { name = "sort", value, options, icon = "chevron-down", direction = "right", multiple = false } = props;
return (
<div>
<Dropdown
value={value}
name="sort"
name={name}
className={ stl.dropdown }
direction={direction}
options={ options }
onChange={ props.onChange }
// floating
scrolling
multiple={ multiple }
selectOnBlur={ false }
// defaultValue={ value }
icon={ icon ? <Icon name="chevron-down" color="gray-dark" size="14" className={stl.dropdownIcon} /> : null }
/>

View file

@ -100,18 +100,20 @@ const FilterDropdown = props => {
</div>
)}
{showDropdown && (
<div className="absolute mt-2 bg-white rounded border p-3 z-20" id="filter-dropdown" style={{ width: '200px'}}>
<div className="font-medium mb-2 tracking-widest color-gray-dark">SELECT FILTER</div>
{filterKeys.filter(f => !filterKeyMaps.includes(f.key)).map(f => (
<div
key={f.key}
onClick={() => onFilterKeySelect(f.key)}
className={cn(stl.filterItem, 'py-3 -mx-3 px-3 flex items-center cursor-pointer')}
>
<Icon name={f.icon} size="16" />
<span className="ml-3 capitalize">{f.name}</span>
</div>
))}
<div className="absolute mt-2 bg-white rounded border z-20" id="filter-dropdown" style={{ width: '200px'}}>
<div className="font-medium mb-2 tracking-widest color-gray-dark p-3">SELECT FILTER</div>
<div className="px-3" style={{ maxHeight: '200px', overflowY: 'auto'}} >
{filterKeys.filter(f => !filterKeyMaps.includes(f.key)).map(f => (
<div
key={f.key}
onClick={() => onFilterKeySelect(f.key)}
className={cn(stl.filterItem, 'py-3 -mx-3 px-3 flex items-center cursor-pointer')}
>
<Icon name={f.icon} size="16" />
<span className="ml-3 capitalize">{f.name}</span>
</div>
))}
</div>
</div>
)}
{filterKey && (

View file

@ -47,15 +47,17 @@ function FilterAutoComplete(props: Props) {
const requestValues = (q) => {
setLoading(true);
return new APIClient()[method?.toLowerCase()](endpoint, { ...params, q })
.then(response => response.json())
.then(({ errors, data }) => {
if (errors) {
// this.setError();
} else {
setOptions(data);
}
}).finally(() => setLoading(false));
return new APIClient()[method?.toLocaleLowerCase()](endpoint, { ...params, q })
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error(response.statusText);
})
.then(({ data }) => {
setOptions(data);
})
.finally(() => setLoading(false));
}
const debouncedRequestValues = React.useCallback(debounce(requestValues, 300), []);

View file

@ -13,6 +13,8 @@ interface Props {
onSelect: (e, item) => void;
value: any;
icon?: string;
type?: string;
isMultilple?: boolean;
}
function FilterAutoCompleteLocal(props: Props) {
@ -24,6 +26,8 @@ function FilterAutoCompleteLocal(props: Props) {
onAddValue = () => null,
value = '',
icon = null,
type = "text",
isMultilple = true,
} = props;
const [showModal, setShowModal] = useState(true)
const [query, setQuery] = useState(value);
@ -59,7 +63,7 @@ function FilterAutoCompleteLocal(props: Props) {
onFocus={ () => setShowModal(true)}
value={ query }
autoFocus={ true }
type="text"
type={ type }
placeholder={ placeholder }
onKeyDown={handleKeyDown}
/>
@ -71,7 +75,7 @@ function FilterAutoCompleteLocal(props: Props) {
</div>
</div>
{ !showOrButton && <div className="ml-3">or</div> }
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
</div>
);
}

View file

@ -4,6 +4,8 @@ import FilterSelection from '../FilterSelection';
import FilterValue from '../FilterValue';
import { Icon } from 'UI';
import FilterSource from '../FilterSource';
import { FilterType } from 'App/types/filter/filterType';
import SubFilterItem from '../SubFilterItem';
interface Props {
filterIndex: number;
@ -15,9 +17,14 @@ interface Props {
function FilterItem(props: Props) {
const { isFilter = false, filterIndex, filter } = props;
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny" || filter.operator === "isUndefined");
const isSubFilter = filter.type === FilterType.SUB_FILTERS;
const replaceFilter = (filter) => {
props.onUpdate({ ...filter, value: [""]});
props.onUpdate({
...filter,
value: [""],
filters: filter.filters ? filter.filters.map(i => ({ ...i, value: [""] })) : []
});
};
const onOperatorChange = (e, { name, value }) => {
@ -28,6 +35,19 @@ function FilterItem(props: Props) {
props.onUpdate({ ...filter, sourceOperator: value })
}
const onUpdateSubFilter = (subFilter, subFilterIndex) => {
props.onUpdate({
...filter,
filters: filter.filters.map((i, index) => {
if (index === subFilterIndex) {
return subFilter;
}
return i;
})
});
};
return (
<div className="flex items-center hover:bg-active-blue -mx-5 px-5 py-2">
<div className="flex items-start w-full">
@ -48,14 +68,31 @@ function FilterItem(props: Props) {
)}
{/* Filter values */}
<FilterOperator
options={filter.operatorOptions}
onChange={onOperatorChange}
className="mx-2 flex-shrink-0"
value={filter.operator}
/>
{ canShowValues && (<FilterValue filter={filter} onUpdate={props.onUpdate} />) }
{ !isSubFilter && (
<>
<FilterOperator
options={filter.operatorOptions}
onChange={onOperatorChange}
className="mx-2 flex-shrink-0"
value={filter.operator}
/>
{ canShowValues && (<FilterValue filter={filter} onUpdate={props.onUpdate} />) }
</>
)}
{/* filters */}
{isSubFilter && (
<div className="grid grid-col ml-3 w-full">
{filter.filters.map((subFilter, subFilterIndex) => (
<SubFilterItem
filterIndex={subFilterIndex}
filter={subFilter}
onUpdate={(f) => onUpdateSubFilter(f, subFilterIndex)}
onRemoveFilter={props.onRemoveFilter}
/>
))}
</div>
)}
</div>
<div className="flex flex-shrink-0 self-start mt-1 ml-auto px-2">
<div

View file

@ -32,7 +32,7 @@ function FilterList(props: Props) {
<div className="mr-2 color-gray-medium text-sm" style={{ textDecoration: 'underline dotted'}}>
<Popup
trigger={<div>Events Order</div>}
content={ `Events Order` }
content={ `Select the operator to be applied between events in your search.` }
size="tiny"
inverted
position="top center"

View file

@ -63,7 +63,7 @@ function FilterValue(props: Props) {
}
const renderValueFiled = (value, valueIndex) => {
const showOrButton = valueIndex === lastIndex;
const showOrButton = valueIndex === lastIndex && filter.type !== FilterType.NUMBER;
switch(filter.type) {
case FilterType.STRING:
return (
@ -113,15 +113,40 @@ function FilterValue(props: Props) {
maxDuration={ durationValues.maxDuration }
/>
)
case FilterType.NUMBER_MULTIPLE:
return (
<FilterAutoCompleteLocal
value={value}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
onAddValue={onAddValue}
onRemoveValue={() => onRemoveValue(valueIndex)}
onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)}
icon={filter.icon}
type="number"
/>
)
case FilterType.NUMBER:
return (
<input
className="w-full px-2 py-1 text-sm leading-tight text-gray-700 rounded-lg"
type="number"
name={`${filter.key}-${valueIndex}`}
<FilterAutoCompleteLocal
value={value}
onChange={(e) => onChange(e, { value: e.target.value }, valueIndex)}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
onAddValue={onAddValue}
onRemoveValue={() => onRemoveValue(valueIndex)}
onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)}
icon={filter.icon}
type="number"
isMultilple={false}
/>
// <input
// className="w-full px-2 py-1 text-sm leading-tight text-gray-700 rounded bg-white border"
// type="number"
// name={`${filter.key}-${valueIndex}`}
// value={value}
// placeholder="Enter"
// onChange={(e) => onChange(e, { value: e.target.value }, valueIndex)}
// />
)
case FilterType.MULTIPLE:
return (

View file

@ -5,6 +5,7 @@
display: flex;
align-items: center;
height: 26px;
width: 100%;
& .right {
height: 24px;

View file

@ -16,31 +16,36 @@ interface Props {
showOrButton?: boolean;
onRemoveValue?: () => void;
onAddValue?: () => void;
isMultilple?: boolean;
}
function FilterValueDropdown(props: Props) {
const { filter, multiple = false, search = false, options, onChange, value, className = '', showCloseButton = true, showOrButton = true } = props;
const { filter, multiple = false, isMultilple = true, search = false, options, onChange, value, className = '', showCloseButton = true, showOrButton = true } = props;
// const options = []
return (
<div className={stl.wrapper}>
<Dropdown
search={search}
className={ cn(stl.operatorDropdown, className, "filterDropdown") }
options={ options }
name="issue_type"
value={ value }
onChange={ onChange }
placeholder="Select"
fluid
icon={ <Icon className="absolute right-0 mr-2" name="chevron-down" size="12" /> }
/>
<div
className={stl.right}
// onClick={showOrButton ? onRemoveValue : onAddValue}
>
{ showCloseButton && <div onClick={props.onRemoveValue}><Icon name="close" size="12" /></div> }
{ showOrButton && <div onClick={props.onAddValue} className="color-teal"><span className="px-1">or</span></div> }
<div className="relative flex items-center w-full">
<div className={stl.wrapper}>
<Dropdown
search={search}
className={ cn(stl.operatorDropdown, className, "filterDropdown") }
options={ options }
name="issue_type"
value={ value }
onChange={ onChange }
placeholder="Select"
fluid
icon={ <Icon className="absolute right-0 mr-2" name="chevron-down" size="12" /> }
/>
<div
className={stl.right}
// onClick={showOrButton ? onRemoveValue : onAddValue}
>
{ showCloseButton && <div onClick={props.onRemoveValue}><Icon name="close" size="12" /></div> }
{ showOrButton && <div onClick={props.onAddValue} className="color-teal"><span className="px-1">or</span></div> }
</div>
</div>
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
</div>
);
}

View file

@ -0,0 +1,34 @@
import { filter } from 'App/components/BugFinder/ManageFilters/savedFilterList.css'
import React from 'react'
import FilterOperator from '../FilterOperator';
import FilterValue from '../FilterValue';
interface Props {
filterIndex: number;
filter: any; // event/filter
onUpdate: (filter) => void;
onRemoveFilter: () => void;
isFilter?: boolean;
}
export default function SubFilterItem(props: Props) {
const { isFilter = false, filterIndex, filter } = props;
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny" || filter.operator === "isUndefined");
const onOperatorChange = (e, { name, value }) => {
props.onUpdate({ ...filter, operator: value })
}
return (
<div className="flex items-center hover:bg-active-blue pb-4">
<div className="flex-shrink-0 py-1">{filter.label}</div>
<FilterOperator
options={filter.operatorOptions}
onChange={onOperatorChange}
className="mx-2 flex-shrink-0"
value={filter.operator}
/>
{ canShowValues && (<FilterValue filter={filter} onUpdate={props.onUpdate} />) }
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './SubFilterItem';

View file

@ -18,12 +18,6 @@ function FunnelSearch(props: Props) {
const onAddFilter = (filter) => {
props.addFilter(filter);
// filter.value = [""]
// const newFilters = appliedFilter.filters.concat(filter);
// props.edit({
// ...appliedFilter.filter,
// filters: newFilters,
// });
}
const onUpdateFilter = (filterIndex, filter) => {

View file

@ -7,7 +7,8 @@ import {
BrowserIcon,
CountryFlag,
Avatar,
TextEllipsis
TextEllipsis,
Label,
} from 'UI';
import { deviceTypeIcon } from 'App/iconNames';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
@ -20,14 +21,15 @@ import Counter from './Counter'
import { withRouter } from 'react-router-dom';
import SessionMetaList from './SessionMetaList';
import ErrorBars from './ErrorBars';
import { assist as assistRoute, isRoute } from "App/routes";
import { assist as assistRoute, liveSession, isRoute } from "App/routes";
import { capitalize } from 'App/utils';
const ASSIST_ROUTE = assistRoute();
const ASSIST_LIVE_SESSION = liveSession()
const Label = ({ label = '', color = 'color-gray-medium'}) => (
<div className={ cn('font-light text-sm', color)}>{label}</div>
)
// const Label = ({ label = '', color = 'color-gray-medium'}) => (
// <div className={ cn('font-light text-sm', color)}>{label}</div>
// )
@connect(state => ({
timezone: state.getIn(['sessions', 'timezone']),
siteId: state.getIn([ 'user', 'siteId' ]),
@ -59,16 +61,18 @@ export default class SessionItem extends React.PureComponent {
metadata,
userSessionsCount,
issueTypes,
active,
},
timezone,
onUserClick = () => null,
hasUserFilter = false,
disableUser = false,
metaList = [],
showActive = false,
} = this.props;
const formattedDuration = durationFormatted(duration);
const hasUserId = userId || userAnonymousId;
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname);
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname) || isRoute(ASSIST_LIVE_SESSION, this.props.location.pathname);
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
const value = metadata[key];
@ -129,6 +133,11 @@ export default class SessionItem extends React.PureComponent {
</div>
<div className="flex items-center">
{ isAssist && showActive && (
<Label success className={cn("bg-green color-white text-right mr-4", { 'opacity-0' : !active})}>
<span className="color-white">ACTIVE</span>
</Label>
)}
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
<Link to={ isAssist ? liveSessionRoute(sessionId) : sessionRoute(sessionId) }>
<Icon name={ !viewed && !isAssist ? 'play-fill' : 'play-circle-light' } size="42" color={isAssist ? "tealx" : "teal"} />

View file

@ -19,12 +19,6 @@ function SessionSearch(props: Props) {
const onAddFilter = (filter) => {
props.addFilter(filter);
// filter.value = [""]
// const newFilters = appliedFilter.filters.concat(filter);
// props.edit({
// ...appliedFilter.filter,
// filters: newFilters,
// });
}
const onUpdateFilter = (filterIndex, filter) => {

View file

@ -1,12 +1,12 @@
import React, { useState } from 'react'
import { connect } from 'react-redux';
import { editGDPR, saveGDPR } from 'Duck/site';
import { Controlled as CodeMirror } from 'react-codemirror2';
import copy from 'copy-to-clipboard';
import { Select, Checkbox } from 'UI';
import GDPR from 'Types/site/gdpr';
import cn from 'classnames'
import styles from './projectCodeSnippet.css'
import Highlight from 'react-highlight'
const inputModeOptions = [
{ text: 'Record all inputs', value: 'plain' },
@ -16,9 +16,11 @@ const inputModeOptions = [
const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
<script>
var initOpts = { projectKey: "PROJECT_KEY", ingestPoint: "https://${window.location.hostname}/ingest"};
var startOpts = { userID: "" };
(function(A,s,a,y,e,r){
r=window.OpenReplay=[s,r,e,[y-1]];
s=document.createElement('script');s.src=a;s.async=!A;
r=window.OpenReplay=[e,r,y,[s-1, e]];
s=document.createElement('script');s.src=A;s.async=!a;
document.getElementsByTagName('head')[0].appendChild(s);
r.start=function(v){r.push([0])};
r.stop=function(v){r.push([1])};
@ -29,8 +31,7 @@ const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
r.issue=function(k,p){r.push([6,k,p])};
r.isActive=function(){return false};
r.getSessionToken=function(){};
r.i="https://${window.location.hostname}/ingest";
})(0, "PROJECT_KEY", "//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",1,XXX);
})("//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",XXX,0,initOpts,startOpts);
</script>`;
@ -132,17 +133,9 @@ const ProjectCodeSnippet = props => {
</div>
<div className={ styles.snippetsWrapper }>
<button className={ styles.codeCopy } onClick={ () => copyHandler(_snippet) }>{ copied ? 'copied' : 'copy' }</button>
<CodeMirror
value={ _snippet }
className={ styles.snippet }
options={{
height: 340,
mode: 'html',
readOnly: true,
showCursorWhenSelecting: false,
scroll: false
}}
/>
<Highlight className="html">
{_snippet}
</Highlight>
</div>
<div className="my-4">You can also setup OpenReplay using <a className="link" href="https://docs.openreplay.com/integrations/google-tag-manager" target="_blank">Google Tag Manager (GTM)</a>. </div>
</div>

View file

@ -0,0 +1,22 @@
import React from 'react'
import { Icon, Popup } from 'UI'
interface Props {
text: string,
className?: string,
position?: string,
}
export default function HelpText(props: Props) {
const { text, className = '', position = 'top center' } = props
return (
<div>
<Popup
trigger={<div className={className}><Icon name="question-circle" size={16} /></div>}
content={text}
inverted
position={position}
/>
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './HelpText';

View file

@ -9,13 +9,14 @@ class SegmentSelection extends React.Component {
}
render() {
const { className, list, small = false, extraSmall = false, primary = false, size = "normal" } = this.props;
const { className, list, small = false, extraSmall = false, primary = false, size = "normal", icons = false } = this.props;
return (
<div className={ cn(styles.wrapper, {
[styles.primary] : primary,
[styles.small] : size === 'small' || small,
[styles.extraSmall] : extraSmall,
[styles.extraSmall] : size === 'extraSmall' || extraSmall,
[styles.icons] : icons === true,
}, className) }
>
{ list.map(item => (
@ -27,8 +28,8 @@ class SegmentSelection extends React.Component {
data-active={ this.props.value && this.props.value.value === item.value }
onClick={ () => !item.disabled && this.setActiveItem(item) }
>
{ item.icon && <Icon name={ item.icon } size="20" marginRight="10" /> }
<div>{ item.name }</div>
{ item.icon && <Icon name={ item.icon } size={(size === "extraSmall" || icons) ? 14 : 20} marginRight={ item.name ? "6" : "" } /> }
<div className="leading-none">{ item.name }</div>
</div>
}
disabled={!item.disabled}

View file

@ -12,13 +12,13 @@
padding: 10px;
flex: 1;
text-align: center;
border-right: solid thin $teal;
cursor: pointer;
background-color: $gray-lightest;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
border-right: solid thin $gray-light;
& span svg {
fill: $gray-medium;
@ -53,9 +53,16 @@
& .item {
color: $teal;
background-color: white;
border-right: solid thin $teal;
& svg {
fill: $teal !important;
}
&[data-active=true] {
background-color: $teal;
color: white;
& svg {
fill: white !important;
}
}
}
}
@ -65,6 +72,11 @@
}
.extraSmall .item {
padding: 0 4px;
padding: 2px 4px !important;
font-size: 12px;
}
.icons .item {
padding: 4px !important;
font-size: 12px;
}

View file

@ -1,12 +1,24 @@
import styles from './slideModal.css';
import cn from 'classnames';
export default class SlideModal extends React.PureComponent {
componentDidMount() {
document.addEventListener('keydown', this.keyPressHandler);
}
// componentDidMount() {
// document.addEventListener('keydown', this.keyPressHandler);
// }
componentWillUnmount() {
document.removeEventListener('keydown', this.keyPressHandler);
// componentWillUnmount() {
// document.removeEventListener('keydown', this.keyPressHandler);
// }
componentDidUpdate(prevProps) {
if (prevProps.isDisplayed !== this.props.isDisplayed) {
if (this.props.isDisplayed) {
document.addEventListener('keydown', this.keyPressHandler);
document.body.classList.add('no-scroll');
} else {
document.removeEventListener('keydown', this.keyPressHandler);
document.body.classList.remove('no-scroll');
}
}
}
keyPressHandler = (e) => {

View file

@ -54,5 +54,6 @@ export { default as CopyButton } from './CopyButton';
export { default as HighlightCode } from './HighlightCode';
export { default as NoPermission } from './NoPermission';
export { default as NoSessionPermission } from './NoSessionPermission';
export { default as HelpText } from './HelpText';
export { Input, Modal, Form, Message, Card } from 'semantic-ui-react';

View file

@ -1,3 +1,5 @@
import { FilterKey } from 'Types/filter/filterType';
export const options = [
{ key: 'on', text: 'on', value: 'on' },
{ key: 'notOn', text: 'not on', value: 'notOn' },
@ -54,6 +56,57 @@ export const customOperators = [
{ key: '>=', text: '>=', value: '>=' },
]
export const metricTypes = [
{ text: 'Timeseries', value: 'timeseries' },
{ text: 'Table', value: 'table' },
];
export const tableColumnName = {
[FilterKey.USERID]: 'User',
[FilterKey.ISSUE]: 'Issue',
[FilterKey.USER_BROWSER]: 'Browser',
[FilterKey.USER_DEVICE]: 'Device',
[FilterKey.USER_COUNTRY]: 'Country',
[FilterKey.LOCATION]: 'URL',
}
export const metricOf = [
{ text: 'Session Count', value: 'sessionCount', type: 'timeseries' },
{ text: 'Users', value: FilterKey.USERID, type: 'table' },
{ text: 'Issues', value: FilterKey.ISSUE, type: 'table' },
{ text: 'Browser', value: FilterKey.USER_BROWSER, type: 'table' },
{ text: 'Device', value: FilterKey.USER_DEVICE, type: 'table' },
{ text: 'Country', value: FilterKey.USER_COUNTRY, type: 'table' },
{ text: 'URL', value: FilterKey.LOCATION, type: 'table' },
]
export const methodOptions = [
{ text: 'GET', value: 'GET' },
{ text: 'POST', value: 'POST' },
{ text: 'PUT', value: 'PUT' },
{ text: 'DELETE', value: 'DELETE' },
{ text: 'PATCH', value: 'PATCH' },
{ text: 'HEAD', value: 'HEAD' },
{ text: 'OPTIONS', value: 'OPTIONS' },
{ text: 'TRACE', value: 'TRACE' },
{ text: 'CONNECT', value: 'CONNECT' },
]
export const issueOptions = [
{ text: 'Click Rage', value: 'click_rage' },
{ text: 'Dead Click', value: 'dead_click' },
{ text: 'Excessive Scrolling', value: 'excessive_scrolling' },
{ text: 'Bad Request', value: 'bad_request' },
{ text: 'Missing Resource', value: 'missing_resource' },
{ text: 'Memory', value: 'memory' },
{ text: 'CPU', value: 'cpu' },
{ text: 'Slow Resource', value: 'slow_resource' },
{ text: 'Slow Page Load', value: 'slow_page_load' },
{ text: 'Crash', value: 'crash' },
{ text: 'Custom', value: 'custom' },
{ text: 'JS Exception', value: 'js_exception' },
]
export default {
options,
baseOperators,
@ -62,4 +115,8 @@ export default {
booleanOperators,
customOperators,
getOperatorsByKeys,
metricTypes,
metricOf,
issueOptions,
methodOptions,
}

View file

@ -187,7 +187,7 @@ export const init = (instance = null, forceNull = false) => (dispatch, getState)
export const fetchSessionList = (params) => (dispatch, getState) => {
dispatch({
types: array(FETCH_SESSION_LIST),
call: client => client.post(`/custom_metrics/sessions`, { ...params }),
call: client => client.post(`/custom_metrics/${params.metricId}/sessions`, { ...params }),
});
}

View file

@ -1,4 +1,4 @@
import { fromJS, List, Map, Set } from 'immutable';
import { List, Map, Set } from 'immutable';
import { errors as errorsRoute, isRoute } from "App/routes";
import Filter from 'Types/filter';
import SavedFilter from 'Types/filter/savedFilter';
@ -8,15 +8,6 @@ import withRequestState, { RequestTypes } from './requestStateCreator';
import { fetchList as fetchSessionList } from './sessions';
import { fetchList as fetchErrorsList } from './errors';
import { fetchListType, fetchType, saveType, editType, initType, removeType } from './funcTools/crud/types';
import logger from 'App/logger';
import { newFiltersList } from 'Types/filter'
import NewFilter, { filtersMap } from 'Types/filter/newFilter';
// for (var i = 0; i < newFiltersList.length; i++) {
// filterOptions[newFiltersList[i].category] = newFiltersList.filter(filter => filter.category === newFiltersList[i].category)
// }
const ERRORS_ROUTE = errorsRoute();
@ -44,11 +35,8 @@ const ADD_ATTRIBUTE = 'filters/ADD_ATTRIBUTE';
const EDIT_ATTRIBUTE = 'filters/EDIT_ATTRIBUTE';
const REMOVE_ATTRIBUTE = 'filters/REMOVE_ATTRIBUTE';
const SET_ACTIVE_FLOW = 'filters/SET_ACTIVE_FLOW';
const UPDATE_VALUE = 'filters/UPDATE_VALUE';
const REFRESH_FILTER_OPTIONS = 'filters/REFRESH_FILTER_OPTIONS';
const initialState = Map({
instance: Filter(),
activeFilter: null,

View file

@ -107,14 +107,15 @@ export const checkFilterValue = (value) => {
return Array.isArray(value) ? (value.length === 0 ? [""] : value) : [value];
}
export const filterMap = ({category, value, key, operator, sourceOperator, source, custom, isEvent }) => ({
export const filterMap = ({category, value, key, operator, sourceOperator, source, custom, isEvent, filters }) => ({
value: checkValues(key, value),
custom,
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
operator,
source: category === FilterCategory.METADATA ? key : source,
sourceOperator,
isEvent
isEvent,
filters: filters ? filters.map(filterMap) : [],
});
const reduceThenFetchResource = actionCreator => (...args) => (dispatch, getState) => {
@ -233,6 +234,10 @@ export const hasFilterApplied = (filters, filter) => {
export const addFilter = (filter) => (dispatch, getState) => {
filter.value = checkFilterValue(filter.value);
filter.filters = filter.filters ? filter.filters.map(subFilter => ({
...subFilter,
value: checkFilterValue(subFilter.value),
})) : null;
const instance = getState().getIn([ 'search', 'instance']);
if (hasFilterApplied(instance.filters, filter)) {

View file

@ -141,4 +141,10 @@
margin: 25px 0;
background-color: $gray-light;
}
.no-scroll {
height: 100vh;
overflow-y: hidden;
padding-right: 15px;
}

View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.17157 3.17157C1.92172 2.42143 2.93913 2 4 2H28C29.0609 2 30.0783 2.42143 30.8284 3.17157C31.5786 3.92172 32 4.93913 32 6V26C32 27.0609 31.5786 28.0783 30.8284 28.8284C30.0783 29.5786 29.0609 30 28 30H4C2.93913 30 1.92172 29.5786 1.17157 28.8284C0.421427 28.0783 0 27.0609 0 26V6C0 4.93913 0.421427 3.92172 1.17157 3.17157ZM30 9V6C30 5.46957 29.7893 4.96086 29.4142 4.58579C29.0391 4.21071 28.5304 4 28 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V9V10V12V13V26C2 26.5304 2.21071 27.0391 2.58579 27.4142C2.96086 27.7893 3.46957 28 4 28H28C28.5304 28 29.0391 27.7893 29.4142 27.4142C29.7893 27.0391 30 26.5304 30 26V13V12V10V9Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5575 19.5675C15.6156 19.6257 15.6845 19.6719 15.7605 19.7034C15.8364 19.7349 15.9178 19.7511 16 19.7511C16.0822 19.7511 16.1636 19.7349 16.2395 19.7034C16.3155 19.6719 16.3844 19.6257 16.4425 19.5675L18.9425 17.0675C19.0006 17.0094 19.0467 16.9404 19.0782 16.8645C19.1096 16.7886 19.1258 16.7072 19.1258 16.625C19.1258 16.5428 19.1096 16.4614 19.0782 16.3855C19.0467 16.3096 19.0006 16.2406 18.9425 16.1825C18.8844 16.1244 18.8154 16.0783 18.7395 16.0468C18.6636 16.0154 18.5822 15.9992 18.5 15.9992C18.4178 15.9992 18.3364 16.0154 18.2605 16.0468C18.1846 16.0783 18.1156 16.1244 18.0575 16.1825L16.625 17.6163V12.875C16.625 12.7092 16.5591 12.5503 16.4419 12.4331C16.3247 12.3158 16.1658 12.25 16 12.25C15.8342 12.25 15.6753 12.3158 15.5581 12.4331C15.4408 12.5503 15.375 12.7092 15.375 12.875V17.6163L13.9425 16.1825C13.8251 16.0651 13.666 15.9992 13.5 15.9992C13.334 15.9992 13.1749 16.0651 13.0575 16.1825C12.9401 16.2999 12.8742 16.459 12.8742 16.625C12.8742 16.791 12.9401 16.9501 13.0575 17.0675L15.5575 19.5675V19.5675Z" fill="black"/>
<path d="M11.5075 10.1775C12.7569 9.10017 14.3503 8.50517 16 8.5C19.3625 8.5 22.1538 11 22.4575 14.2237C24.4475 14.505 26 16.1712 26 18.2162C26 20.4612 24.1275 22.25 21.8588 22.25H10.7262C8.135 22.25 6 20.2075 6 17.6475C6 15.4437 7.5825 13.6187 9.6775 13.1562C9.85625 12.0775 10.55 11.0025 11.5075 10.1775V10.1775ZM12.3238 11.1237C11.3775 11.94 10.8825 12.9238 10.8825 13.6938V14.2538L10.3262 14.315C8.58 14.5062 7.25 15.94 7.25 17.6475C7.25 19.4812 8.7875 21 10.7262 21H21.8588C23.475 21 24.75 19.735 24.75 18.2162C24.75 16.6962 23.475 15.4313 21.8588 15.4313H21.2338V14.8063C21.235 12.0313 18.91 9.75 16 9.75C14.6498 9.75539 13.3461 10.243 12.3238 11.125V11.1237Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-graph-up-arrow" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M0 0h1v15h15v1H0V0Zm10 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0V4.9l-3.613 4.417a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61L13.445 4H10.5a.5.5 0 0 1-.5-.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hash" viewBox="0 0 16 16">
<path d="M8.39 12.648a1.32 1.32 0 0 0-.015.18c0 .305.21.508.5.508.266 0 .492-.172.555-.477l.554-2.703h1.204c.421 0 .617-.234.617-.547 0-.312-.188-.53-.617-.53h-.985l.516-2.524h1.265c.43 0 .618-.227.618-.547 0-.313-.188-.524-.618-.524h-1.046l.476-2.304a1.06 1.06 0 0 0 .016-.164.51.51 0 0 0-.516-.516.54.54 0 0 0-.539.43l-.523 2.554H7.617l.477-2.304c.008-.04.015-.118.015-.164a.512.512 0 0 0-.523-.516.539.539 0 0 0-.531.43L6.53 5.484H5.414c-.43 0-.617.22-.617.532 0 .312.187.539.617.539h.906l-.515 2.523H4.609c-.421 0-.609.219-.609.531 0 .313.188.547.61.547h.976l-.516 2.492c-.008.04-.015.125-.015.18 0 .305.21.508.5.508.265 0 .492-.172.554-.477l.555-2.703h2.242l-.515 2.492zm-1-6.109h2.266l-.515 2.563H6.859l.532-2.563z"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pie-chart-fill" viewBox="0 0 16 16">
<path d="M15.985 8.5H8.207l-5.5 5.5a8 8 0 0 0 13.277-5.5zM2 13.292A8 8 0 0 1 7.5.015v7.778l-5.5 5.5zM8.5.015V7.5h7.485A8.001 8.001 0 0 0 8.5.015z"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-table" viewBox="0 0 16 16">
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View file

@ -103,5 +103,11 @@ export default Record({
endTimestamp: this.end,
};
},
toTimestampstwo() {
return {
startTimestamp: this.start / 1000,
endTimestamp: this.end / 1000,
};
},
}
});

View file

@ -3,6 +3,7 @@ import { List } from 'immutable';
import Filter from 'Types/filter';
import { validateName } from 'App/validate';
import { LAST_7_DAYS } from 'Types/app/period';
import { FilterKey } from 'Types/filter/filterType';
import { filterMap } from 'Duck/search';
export const FilterSeries = Record({
@ -27,6 +28,10 @@ export const FilterSeries = Record({
export default Record({
metricId: undefined,
name: 'Series',
metricType: 'timeseries',
metricOf: 'sessionCount',
metricValue: ['sessionCount'],
metricFormat: 'sessionCount',
viewType: 'lineChart',
series: List(),
isPublic: true,
@ -43,11 +48,13 @@ export default Record({
toSaveData() {
const js = this.toJS();
js.metricValue = js.metricValue.map(value => value === 'all' ? '' : value);
js.series = js.series.map(series => {
series.filter.filters = series.filter.filters.map(filterMap);
// delete series._key
// delete series.key
delete series.key
return series;
});
@ -61,8 +68,10 @@ export default Record({
return js;
},
},
fromJS: ({ series, ...rest }) => ({
fromJS: ({ metricOf, metricValue, series, ...rest }) => ({
...rest,
series: List(series).map(FilterSeries),
metricOf,
metricValue: metricOf === FilterKey.ISSUE && metricValue.length === 0 ? ['all'] : metricValue,
}),
});

View file

@ -96,8 +96,14 @@ export default Record({
startDate,
endDate,
events: List(events).map(Event),
filters: List(filters).map(i => NewFilter(i).toData()).concat(List(events).map(i => NewFilter(i).toData())),
custom: Map(custom),
filters: List(filters)
.map(i => {
const filter = NewFilter(i).toData();
if (i.hasOwnProperty('filters')) {
filter.filters = i.filters.map(f => NewFilter({...f, subFilter: i.type}).toData());
}
return filter;
}),
}
}
});

View file

@ -13,8 +13,10 @@ export enum FilterType {
ISSUE = "ISSUE",
BOOLEAN = "BOOLEAN",
NUMBER = "NUMBER",
NUMBER_MULTIPLE = "NUMBER_MULTIPLE",
DURATION = "DURATION",
MULTIPLE = "MULTIPLE",
SUB_FILTERS = "SUB_FILTERS",
COUNTRY = "COUNTRY",
DROPDOWN = "DROPDOWN",
MULTIPLE_DROPDOWN = "MULTIPLE_DROPDOWN",
@ -61,4 +63,11 @@ export enum FilterKey {
AVG_CPU_LOAD = "AVG_CPU_LOAD",
AVG_MEMORY_USAGE = "AVG_MEMORY_USAGE",
FETCH_FAILED = "FETCH_FAILED",
FETCH = "FETCH",
FETCH_URL = "FETCH_URL",
FETCH_STATUS_CODE = "FETCH_STATUS_CODE",
FETCH_METHOD = "FETCH_METHOD",
FETCH_DURATION = "FETCH_DURATION",
FETCH_REQUEST_BODY = "FETCH_REQUEST_BODY",
FETCH_RESPONSE_BODY = "FETCH_RESPONSE_BODY",
}

Some files were not shown because too many files have changed in this diff Show more