Merge pull request #184 from openreplay/dev

v1.3.5
This commit is contained in:
Shekar Siri 2021-09-24 12:45:46 +05:30 committed by GitHub
commit 2ce6ff8c79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 2771 additions and 4271 deletions

View file

@ -33,7 +33,7 @@
"sourcemaps_reader": "http://utilities-openreplay.app.svc.cluster.local:9000/sourcemaps",
"sourcemaps_bucket": "sourcemaps",
"js_cache_bucket": "sessions-assets",
"peers": "http://utilities-openreplay.app.svc.cluster.local:9000/assist/peers",
"peers": "http://utilities-openreplay.app.svc.cluster.local:9000/assist/%s/peers",
"async_Token": "",
"EMAIL_HOST": "",
"EMAIL_PORT": "587",

View file

@ -7,7 +7,7 @@ from chalicelib.blueprints import bp_authorizers
from chalicelib.blueprints import bp_core, bp_core_crons
from chalicelib.blueprints.app import v1_api
from chalicelib.blueprints import bp_core_dynamic, bp_core_dynamic_crons
from chalicelib.blueprints.subs import bp_dashboard
from chalicelib.blueprints.subs import bp_dashboard,bp_insights
from chalicelib.utils import helper
from chalicelib.utils import pg_client
from chalicelib.utils.helper import environ
@ -106,4 +106,5 @@ app.register_blueprint(bp_core_crons.app)
app.register_blueprint(bp_core_dynamic.app)
app.register_blueprint(bp_core_dynamic_crons.app)
app.register_blueprint(bp_dashboard.app)
app.register_blueprint(bp_insights.app)
app.register_blueprint(v1_api.app)

View file

@ -0,0 +1,69 @@
from chalice import Blueprint
from chalicelib.utils import helper
from chalicelib import _overrides
from chalicelib.core import dashboard, insights
from chalicelib.core import metadata
app = Blueprint(__name__)
_overrides.chalice_app(app)
#
# @app.route('/{projectId}/dashboard/metadata', methods=['GET'])
# def get_metadata_map(projectId, context):
# metamap = []
# for m in metadata.get(project_id=projectId):
# metamap.append({"name": m["key"], "key": f"metadata{m['index']}"})
# return {"data": metamap}
#
#
@app.route('/{projectId}/insights/journey', methods=['GET', 'POST'])
def get_insights_journey(projectId, context):
data = app.current_request.json_body
if data is None:
data = {}
params = app.current_request.query_params
args = dashboard.dashboard_args(params)
return {"data": insights.get_journey(project_id=projectId, **{**data, **args})}
@app.route('/{projectId}/insights/users_retention', methods=['GET', 'POST'])
def get_users_retention(projectId, context):
data = app.current_request.json_body
if data is None:
data = {}
params = app.current_request.query_params
args = dashboard.dashboard_args(params)
return {"data": insights.get_retention(project_id=projectId, **{**data, **args})}
#
#
# @app.route('/{projectId}/dashboard/{widget}/search', methods=['GET'])
# def get_dashboard_autocomplete(projectId, widget, context):
# params = app.current_request.query_params
# if params is None or params.get('q') is None or len(params.get('q')) == 0:
# return {"data": []}
# params['q'] = '^' + params['q']
#
# if widget in ['performance']:
# data = dashboard.search(params.get('q', ''), params.get('type', ''), project_id=projectId,
# platform=params.get('platform', None), performance=True)
# elif widget in ['pages', 'pages_dom_buildtime', 'top_metrics', 'time_to_render',
# 'impacted_sessions_by_slow_pages', 'pages_response_time']:
# data = dashboard.search(params.get('q', ''), params.get('type', ''), project_id=projectId,
# platform=params.get('platform', None), pages_only=True)
# elif widget in ['resources_loading_time']:
# data = dashboard.search(params.get('q', ''), params.get('type', ''), project_id=projectId,
# platform=params.get('platform', None), performance=False)
# elif widget in ['time_between_events', 'events']:
# data = dashboard.search(params.get('q', ''), params.get('type', ''), project_id=projectId,
# platform=params.get('platform', None), performance=False, events_only=True)
# elif widget in ['metadata']:
# data = dashboard.search(params.get('q', ''), None, project_id=projectId,
# platform=params.get('platform', None), metadata=True, key=params.get("key"))
# else:
# return {"errors": [f"unsupported widget: {widget}"]}
# return {'data': data}

View file

@ -21,7 +21,7 @@ SESSION_PROJECTION_COLS = """s.project_id,
def get_live_sessions(project_id, filters=None):
project_key = projects.get_project_key(project_id)
connected_peers = requests.get(environ["peers"] + f"/{project_key}")
connected_peers = requests.get(environ["peers"] % environ["S3_KEY"] + f"/{project_key}")
if connected_peers.status_code != 200:
print("!! issue with the peer-server")
print(connected_peers.text)
@ -65,7 +65,7 @@ def get_live_sessions(project_id, filters=None):
def is_live(project_id, session_id, project_key=None):
if project_key is None:
project_key = projects.get_project_key(project_id)
connected_peers = requests.get(environ["peers"] + f"/{project_key}")
connected_peers = requests.get(environ["peers"] % environ["S3_KEY"] + f"/{project_key}")
if connected_peers.status_code != 200:
print("!! issue with the peer-server")
print(connected_peers.text)

View file

@ -0,0 +1,211 @@
from chalicelib.core import sessions_metas
from chalicelib.utils import args_transformer
from chalicelib.utils import helper, dev
from chalicelib.utils import pg_client
from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.utils.metrics_helper import __get_step_size
import math
from chalicelib.core.dashboard import __get_constraints, __get_constraint_values
def __transform_journey(rows):
nodes = []
links = []
for r in rows:
source = r["source_event"][r["source_event"].index("_"):]
target = r["target_event"][r["target_event"].index("_"):]
if source not in nodes:
nodes.append(source)
if target not in nodes:
nodes.append(target)
links.append({"source": nodes.index(source), "target": nodes.index(target), "value": r["value"]})
return {"nodes": nodes, "links": sorted(links, key=lambda x: x["value"], reverse=True)}
JOURNEY_DEPTH = 5
JOURNEY_TYPES = {
"PAGES": {"table": "events.pages", "column": "base_path", "table_id": "message_id"},
"CLICK": {"table": "events.clicks", "column": "label", "table_id": "message_id"},
"VIEW": {"table": "events_ios.views", "column": "name", "table_id": "seq_index"},
"EVENT": {"table": "events_common.customs", "column": "name", "table_id": "seq_index"}
}
@dev.timed
def get_journey(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), filters=[], **args):
pg_sub_query_subset = __get_constraints(project_id=project_id, data=args, duration=True, main_table="sessions",
time_constraint=True)
event_start = None
event_table = JOURNEY_TYPES["PAGES"]["table"]
event_column = JOURNEY_TYPES["PAGES"]["column"]
event_table_id = JOURNEY_TYPES["PAGES"]["table_id"]
extra_values = {}
for f in filters:
if f["type"] == "START_POINT":
event_start = f["value"]
elif f["type"] == "EVENT_TYPE" and JOURNEY_TYPES.get(f["value"]):
event_table = JOURNEY_TYPES[f["value"]]["table"]
event_column = JOURNEY_TYPES[f["value"]]["column"]
elif f["type"] in [sessions_metas.meta_type.USERID, sessions_metas.meta_type.USERID_IOS]:
pg_sub_query_subset.append(f"sessions.user_id = %(user_id)s")
extra_values["user_id"] = f["value"]
with pg_client.PostgresClient() as cur:
pg_query = f"""SELECT source_event,
target_event,
MAX(target_id) max_target_id,
MAX(source_id) max_source_id,
count(*) AS value
FROM (SELECT event_number || '_' || value as target_event,
message_id AS target_id,
LAG(event_number || '_' || value, 1) OVER ( PARTITION BY session_rank ) AS source_event,
LAG(message_id, 1) OVER ( PARTITION BY session_rank ) AS source_id
FROM (SELECT value,
session_rank,
message_id,
ROW_NUMBER() OVER ( PARTITION BY session_rank ORDER BY timestamp ) AS event_number
{f"FROM (SELECT * FROM (SELECT *, MIN(mark) OVER ( PARTITION BY session_id , session_rank ORDER BY timestamp ) AS max FROM (SELECT *, CASE WHEN value = %(event_start)s THEN timestamp ELSE NULL END as mark"
if event_start else ""}
FROM (SELECT session_id,
message_id,
timestamp,
value,
SUM(new_session) OVER (ORDER BY session_id, timestamp) AS session_rank
FROM (SELECT *,
CASE
WHEN source_timestamp IS NULL THEN 1
ELSE 0 END AS new_session
FROM (SELECT session_id,
{event_table_id} AS message_id,
timestamp,
{event_column} AS value,
LAG(timestamp)
OVER (PARTITION BY session_id ORDER BY timestamp) AS source_timestamp
FROM {event_table} INNER JOIN public.sessions USING (session_id)
WHERE {" AND ".join(pg_sub_query_subset)}
) AS related_events) AS ranked_events) AS processed
{") AS marked) AS maxed WHERE timestamp >= max) AS filtered" if event_start else ""}
) AS sorted_events
WHERE event_number <= %(JOURNEY_DEPTH)s) AS final
WHERE source_event IS NOT NULL
and target_event IS NOT NULL
GROUP BY source_event, target_event
ORDER BY value DESC
LIMIT 20;"""
params = {"project_id": project_id, "startTimestamp": startTimestamp,
"endTimestamp": endTimestamp, "event_start": event_start, "JOURNEY_DEPTH": JOURNEY_DEPTH,
**__get_constraint_values(args), **extra_values}
# print(cur.mogrify(pg_query, params))
cur.execute(cur.mogrify(pg_query, params))
rows = cur.fetchall()
return __transform_journey(rows)
def __compute_retention_percentage(rows):
if rows is None or len(rows) == 0:
return rows
t = -1
for r in rows:
if r["week"] == 0:
t = r["usersCount"]
r["percentage"] = r["usersCount"] / t
return rows
def __complete_retention(rows, start_date, end_date=None):
if rows is None or len(rows) == 0:
return rows
max_week = 10
week = 0
delta_date = 0
while max_week > 0:
start_date += TimeUTC.MS_WEEK
if end_date is not None and start_date >= end_date:
break
delta = 0
if delta_date + week >= len(rows) \
or delta_date + week < len(rows) and rows[delta_date + week]["firstConnexionWeek"] > start_date:
for i in range(max_week):
if end_date is not None and start_date + i * TimeUTC.MS_WEEK >= end_date:
break
neutral = {
"firstConnexionWeek": start_date,
"week": i,
"usersCount": 0,
"connectedUsers": [],
"percentage": 0
}
rows.insert(delta_date + week + i, neutral)
delta = i
else:
for i in range(max_week):
if end_date is not None and start_date + i * TimeUTC.MS_WEEK >= end_date:
break
neutral = {
"firstConnexionWeek": start_date,
"week": i,
"usersCount": 0,
"connectedUsers": [],
"percentage": 0
}
if delta_date + week + i < len(rows) \
and i != rows[delta_date + week + i]["week"]:
rows.insert(delta_date + week + i, neutral)
elif delta_date + week + i >= len(rows):
rows.append(neutral)
delta = i
week += delta
max_week -= 1
delta_date += 1
return rows
@dev.timed
def get_retention(project_id, startTimestamp=TimeUTC.now(delta_days=-70), endTimestamp=TimeUTC.now(), filters=[],
**args):
startTimestamp = TimeUTC.trunc_week(startTimestamp)
endTimestamp = startTimestamp + 10 * TimeUTC.MS_WEEK
pg_sub_query = __get_constraints(project_id=project_id, data=args, duration=True, main_table="sessions",
time_constraint=True)
with pg_client.PostgresClient() as cur:
pg_query = f"""SELECT EXTRACT(EPOCH FROM first_connexion_week::date)::bigint*1000 AS first_connexion_week,
FLOOR(DATE_PART('day', connexion_week - first_connexion_week) / 7)::integer AS week,
COUNT(DISTINCT connexions_list.user_id) AS users_count,
ARRAY_AGG(DISTINCT connexions_list.user_id) AS connected_users
FROM (SELECT DISTINCT user_id, MIN(DATE_TRUNC('week', to_timestamp(start_ts / 1000))) AS first_connexion_week
FROM sessions
WHERE {" AND ".join(pg_sub_query)}
AND user_id IS NOT NULL
AND NOT EXISTS((SELECT 1
FROM sessions AS bsess
WHERE bsess.start_ts<EXTRACT('EPOCH' FROM DATE_TRUNC('week', to_timestamp(%(startTimestamp)s / 1000))) * 1000
AND project_id = %(project_id)s
AND bsess.user_id = sessions.user_id
LIMIT 1))
GROUP BY user_id) AS users_list
LEFT JOIN LATERAL (SELECT DATE_TRUNC('week', to_timestamp(start_ts / 1000)::timestamp) AS connexion_week,
user_id
FROM sessions
WHERE users_list.user_id = sessions.user_id
AND first_connexion_week <=
DATE_TRUNC('week', to_timestamp(sessions.start_ts / 1000)::timestamp)
AND sessions.project_id = 1
AND sessions.start_ts < (%(endTimestamp)s - 1)
GROUP BY connexion_week, user_id) AS connexions_list ON (TRUE)
GROUP BY first_connexion_week, week
ORDER BY first_connexion_week, week;"""
params = {"project_id": project_id, "startTimestamp": startTimestamp,
"endTimestamp": endTimestamp, **__get_constraint_values(args)}
# print(cur.mogrify(pg_query, params))
cur.execute(cur.mogrify(pg_query, params))
rows = cur.fetchall()
rows = __compute_retention_percentage(helper.list_to_camel_case(rows))
return __complete_retention(rows=rows, start_date=startTimestamp, end_date=TimeUTC.now())

View file

@ -426,8 +426,27 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
if auth is None:
return {"errors": ["wrong password"]}
changes = {"password": new_password, "generatedPassword": False}
return {"data": update(tenant_id=tenant_id, user_id=user_id, changes=changes),
"jwt": authenticate(email, new_password)["jwt"]}
user = update(tenant_id=tenant_id, user_id=user_id, changes=changes)
r = authenticate(user['email'], new_password)
tenant_id = r.pop("tenantId")
r["limits"] = {
"teamMember": -1,
"projects": -1,
"metadata": metadata.get_remaining_metadata_with_count(tenant_id)}
c = tenants.get_by_tenant_id(tenant_id)
c.pop("createdAt")
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
stack_integrations=True)
c["smtp"] = helper.has_smtp()
return {
'jwt': r.pop('jwt'),
'data': {
"user": r,
"client": c
}
}
def set_password_invitation(user_id, new_password):

View file

@ -7,6 +7,7 @@ class TimeUTC:
MS_MINUTE = 60 * 1000
MS_HOUR = MS_MINUTE * 60
MS_DAY = MS_HOUR * 24
MS_WEEK = MS_DAY * 7
MS_MONTH = MS_DAY * 30
MS_MONTH_TRUE = monthrange(datetime.now(pytz.utc).astimezone(pytz.utc).year,
datetime.now(pytz.utc).astimezone(pytz.utc).month)[1] * MS_DAY
@ -113,3 +114,11 @@ class TimeUTC:
@staticmethod
def get_utc_offset():
return int((datetime.now(pytz.utc).now() - datetime.now(pytz.utc).replace(tzinfo=None)).total_seconds() * 1000)
@staticmethod
def trunc_week(timestamp):
dt = TimeUTC.from_ms_timestamp(timestamp)
start = dt - timedelta(days=dt.weekday())
return TimeUTC.datetime_to_timestamp(start
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(pytz.utc))

View file

@ -51,7 +51,7 @@ class PostgresClient:
try:
self.connection.commit()
self.cursor.close()
except:
except Exception as error:
print("Error while committing/closing PG-connection", error)
raise error
finally:

View file

@ -1,5 +1,5 @@
requests==2.24.0
urllib3==1.25.11
requests==2.26.0
urllib3==1.26.6
boto3==1.16.1
pyjwt==1.7.1
psycopg2-binary==2.8.6

View file

@ -25,7 +25,7 @@ ENV TZ=UTC \
MAXMINDDB_FILE=/root/geoip.mmdb \
UAPARSER_FILE=/root/regexes.yaml \
HTTP_PORT=80 \
BEACON_SIZE_LIMIT=1000000 \
BEACON_SIZE_LIMIT=7000000 \
KAFKA_USE_SSL=true \
REDIS_STREAMS_MAX_LEN=3000 \
TOPIC_RAW=raw \

View file

@ -36,8 +36,13 @@ func Uint16(key string) uint16 {
return uint16(n)
}
const MAX_INT = uint64(^uint(0) >> 1)
func Int(key string) int {
return int(Uint64(key))
val := Uint64(key)
if val > MAX_INT {
log.Fatalln(key + " is too big. ")
}
return int(val)
}
func Bool(key string) bool {

View file

@ -3,7 +3,7 @@ package messages
func IsReplayerType(id uint64) bool {
return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 22 == id || 37 == id || 38 == id || 39 == id || 40 == id || 41 == id || 44 == id || 45 == id || 46 == id || 47 == id || 48 == id || 49 == id || 54 == id || 55 == id || 59 == id || 90 == id || 93 == id || 100 == id || 102 == id || 103 == id || 105 == id
return 0 == id || 2 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 22 == id || 37 == id || 38 == id || 39 == id || 40 == id || 41 == id || 44 == id || 45 == id || 46 == id || 47 == id || 48 == id || 49 == id || 54 == id || 55 == id || 59 == id || 69 == id || 70 == id || 90 == id || 93 == id || 100 == id || 102 == id || 103 == id || 105 == id
}
func IsIOSType(id uint64) bool {

View file

@ -86,6 +86,18 @@ p = WriteString(msg.UserCountry, buf, p)
return buf[:p]
}
type SessionDisconnect struct {
*meta
Timestamp uint64
}
func (msg *SessionDisconnect) Encode() []byte{
buf := make([]byte, 11 )
buf[0] = 2
p := 1
p = WriteUint(msg.Timestamp, buf, p)
return buf[:p]
}
type SessionEnd struct {
*meta
Timestamp uint64
@ -1166,6 +1178,20 @@ p = WriteString(msg.Selector, buf, p)
return buf[:p]
}
type CreateIFrameDocument struct {
*meta
FrameID uint64
ID uint64
}
func (msg *CreateIFrameDocument) Encode() []byte{
buf := make([]byte, 21 )
buf[0] = 70
p := 1
p = WriteUint(msg.FrameID, buf, p)
p = WriteUint(msg.ID, buf, p)
return buf[:p]
}
type IOSSessionStart struct {
*meta
Timestamp uint64

View file

@ -44,6 +44,11 @@ if msg.UserDeviceHeapSize, err = ReadUint(reader); err != nil { return nil, err
if msg.UserCountry, err = ReadString(reader); err != nil { return nil, err }
return msg, nil
case 2:
msg := &SessionDisconnect{ meta: &meta{ TypeID: 2} }
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
return msg, nil
case 3:
msg := &SessionEnd{ meta: &meta{ TypeID: 3} }
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
@ -521,6 +526,12 @@ if msg.Label, err = ReadString(reader); err != nil { return nil, err }
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
return msg, nil
case 70:
msg := &CreateIFrameDocument{ meta: &meta{ TypeID: 70} }
if msg.FrameID, err = ReadUint(reader); err != nil { return nil, err }
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
return msg, nil
case 90:
msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} }
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }

View file

@ -66,13 +66,12 @@ func ResolveCSS(baseURL string, css string) string {
css = rewriteLinks(css, func(rawurl string) string {
return ResolveURL(baseURL, rawurl)
})
return strings.Replace(css, ":hover", ".-asayer-hover", -1)
return strings.Replace(css, ":hover", ".-openreplay-hover", -1)
}
func (r *Rewriter) RewriteCSS(sessionID uint64, baseurl string, css string) string {
css = rewriteLinks(css, func(rawurl string) string {
url , _ := r.RewriteURL(sessionID, baseurl, rawurl)
return url
return r.RewriteURL(sessionID, baseurl, rawurl)
})
return strings.Replace(css, ":hover", ".-asayer-hover", -1)
return strings.Replace(css, ":hover", ".-openreplay-hover", -1)
}

View file

@ -50,23 +50,15 @@ func GetFullCachableURL(baseURL string, relativeURL string) (string, bool) {
if !isRelativeCachable(relativeURL) {
return "", false
}
return ResolveURL(baseURL, relativeURL), true
fullURL := ResolveURL(baseURL, relativeURL)
if !isCachable(fullURL) {
return "", false
}
return fullURL, true
}
const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY"
func getCachePath(rawurl string) string {
return "/" + strings.ReplaceAll(url.QueryEscape(rawurl), "%", "!") // s3 keys are ok with "!"
// u, _ := url.Parse(rawurl)
// s := "/" + u.Scheme + "/" + u.Hostname() + u.Path
// if u.RawQuery != "" {
// if (s[len(s) - 1] != '/') {
// s += "/"
// }
// s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery)
// }
// return s
}
func getCachePathWithKey(sessionID uint64, rawurl string) string {
@ -82,14 +74,10 @@ func GetCachePathForAssets(sessionID uint64, rawurl string) string {
}
func (r *Rewriter) RewriteURL(sessionID uint64, baseURL string, relativeURL string) (string, bool) {
// TODO: put it in one check within GetFullCachableURL
if !isRelativeCachable(relativeURL) {
return relativeURL, false
}
fullURL := ResolveURL(baseURL, relativeURL)
if !isCachable(fullURL) {
return relativeURL, false
func (r *Rewriter) RewriteURL(sessionID uint64, baseURL string, relativeURL string) string {
fullURL, cachable := GetFullCachableURL(baseURL, relativeURL)
if !cachable {
return relativeURL
}
u := url.URL{
@ -98,6 +86,6 @@ func (r *Rewriter) RewriteURL(sessionID uint64, baseURL string, relativeURL stri
Scheme: r.assetsURL.Scheme,
}
return u.String(), true
return u.String()
}

View file

@ -21,11 +21,8 @@ func sendAssetsForCacheFromCSS(sessionID uint64, baseURL string, css string) {
func handleURL(sessionID uint64, baseURL string, url string) string {
if CACHE_ASSESTS {
rewrittenURL, isCachable := rewriter.RewriteURL(sessionID, baseURL, url)
if isCachable {
sendAssetForCache(sessionID, baseURL, url)
}
return rewrittenURL
sendAssetForCache(sessionID, baseURL, url)
return rewriter.RewriteURL(sessionID, baseURL, url)
}
return assets.ResolveURL(baseURL, url)
}

View file

@ -34,11 +34,12 @@ func startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) {
Reset bool `json:"reset"`
}
type response struct {
Timestamp int64 `json:"timestamp"`
Delay int64 `json:"delay"`
Token string `json:"token"`
UserUUID string `json:"userUUID"`
SessionID string `json:"sessionID"`
Timestamp int64 `json:"timestamp"`
Delay int64 `json:"delay"`
Token string `json:"token"`
UserUUID string `json:"userUUID"`
SessionID string `json:"sessionID"`
BeaconSizeLimit int64 `json:"beaconSizeLimit"`
}
startTime := time.Now()
@ -115,6 +116,7 @@ func startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) {
Token: tokenizer.Compose(*tokenData),
UserUUID: userUUID,
SessionID: strconv.FormatUint(tokenData.ID, 10),
BeaconSizeLimit: BEACON_SIZE_LIMIT,
})
}

View file

@ -8,6 +8,8 @@ import (
"os/signal"
"syscall"
"golang.org/x/net/http2"
"openreplay/backend/pkg/env"
"openreplay/backend/pkg/flakeid"
@ -131,6 +133,7 @@ func main() {
}
}),
}
http2.ConfigureServer(server, nil)
go func() {
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server error: %v\n", err)

View file

@ -15,7 +15,7 @@ import (
"openreplay/backend/pkg/queue/types"
)
func main() {
log.SetFlags(log.LstdFlags | log.LUTC | log.Llongfile)

View file

@ -35,7 +35,7 @@
"put_S3_TTL": "20",
"sourcemaps_reader": "http://utilities-openreplay.app.svc.cluster.local:9000/sourcemaps",
"sourcemaps_bucket": "sourcemaps",
"peers": "http://utilities-openreplay.app.svc.cluster.local:9000/assist/peers",
"peers": "http://utilities-openreplay.app.svc.cluster.local:9000/assist/%s/peers",
"js_cache_bucket": "sessions-assets",
"async_Token": "",
"EMAIL_HOST": "",

View file

@ -436,8 +436,27 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
if auth is None:
return {"errors": ["wrong password"]}
changes = {"password": new_password, "generatedPassword": False}
return {"data": update(tenant_id=tenant_id, user_id=user_id, changes=changes),
"jwt": authenticate(email, new_password)["jwt"]}
user = update(tenant_id=tenant_id, user_id=user_id, changes=changes)
r = authenticate(user['email'], new_password)
tenant_id = r.pop("tenantId")
r["limits"] = {
"teamMember": -1,
"projects": -1,
"metadata": metadata.get_remaining_metadata_with_count(tenant_id)}
c = tenants.get_by_tenant_id(tenant_id)
c.pop("createdAt")
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
stack_integrations=True)
c["smtp"] = helper.has_smtp()
return {
'jwt': r.pop('jwt'),
'data': {
"user": r,
"client": c,
}
}
def set_password_invitation(tenant_id, user_id, new_password):
@ -457,6 +476,7 @@ def set_password_invitation(tenant_id, user_id, new_password):
c.pop("createdAt")
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
stack_integrations=True)
c["smtp"] = helper.has_smtp()
return {
'jwt': r.pop('jwt'),
'data': {

View file

@ -1,5 +1,5 @@
requests==2.24.0
urllib3==1.25.11
requests==2.26.0
urllib3==1.26.6
boto3==1.16.1
pyjwt==1.7.1
psycopg2-binary==2.8.6

View file

@ -10,6 +10,19 @@ import { CallingState, ConnectionStatus } from 'Player/MessageDistributor/manage
import { toast } from 'react-toastify';
import stl from './AassistActions.css'
function onClose(stream) {
stream.getTracks().forEach(t=>t.stop());
}
function onReject() {
toast.info(`Call was rejected.`);
}
function onError(e) {
toast.error(e);
}
interface Props {
userId: String,
toggleChatWindow: (state) => void,
@ -32,18 +45,6 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
}
}, [peerConnectionStatus])
function onClose(stream) {
stream.getTracks().forEach(t=>t.stop());
}
function onReject() {
toast.info(`Call was rejected.`);
}
function onError(e) {
toast.error(e);
}
function onCallConnect(lStream) {
setLocalStream(lStream);
setEndCall(() => callPeer(

View file

@ -24,7 +24,7 @@ const TrackerUpdateMessage= (props) => {
<Icon name="info-circle" size="14" color="gray-darkest" />
</div>
<div className="ml-2color-gray-darkest mr-auto">
Please <a href="#" className="link" onClick={() => props.history.push(withSiteId(onboardingRoute('installing'), siteId))}>update</a> your tracker (Asayer) to the latest OpenReplay version ({window.ENV.TRACKER_VERSION}) to benefit from all new features we recently shipped.
There might be a mismatch between the tracker and the backend versions. Please make sure to <a href="#" className="link" onClick={() => props.history.push(withSiteId(onboardingRoute('installing'), siteId))}>update</a> the tracker to latest version (<a href="https://www.npmjs.com/package/@openreplay/tracker" target="_blank">{window.ENV.TRACKER_VERSION}</a>).
</div>
</div>
</div>

View file

@ -10,6 +10,7 @@ import { update, getState } from '../../store';
export enum CallingState {
Reconnecting,
Requesting,
True,
False,
@ -38,7 +39,7 @@ export function getStatusText(status: ConnectionStatus): string {
case ConnectionStatus.Error:
return "Something went wrong. Try to reload the page.";
case ConnectionStatus.WaitingMessages:
return "Connected. Waiting for the data..."
return "Connected. Waiting for the data... (The tab might be inactive)"
}
}
@ -187,6 +188,13 @@ export default class AssistManager {
const conn = this.peer.connect(id, { serialization: 'json', reliable: true});
conn.on('open', () => {
window.addEventListener("beforeunload", ()=>conn.open &&conn.send("unload"));
//console.log("peer connected")
if (getState().calling === CallingState.Reconnecting) {
this._call()
}
let i = 0;
let firstMessage = true;
@ -195,7 +203,7 @@ export default class AssistManager {
conn.on('data', (data) => {
if (!Array.isArray(data)) { return this.handleCommand(data); }
this.mesagesRecieved = true;
this.disconnectTimeout && clearTimeout(this.disconnectTimeout);
if (firstMessage) {
firstMessage = false;
this.setStatus(ConnectionStatus.Connected)
@ -246,8 +254,8 @@ export default class AssistManager {
const onDataClose = () => {
this.initiateCallEnd();
this.setStatus(ConnectionStatus.Connecting);
this.onCallDisconnect()
//console.log('closed peer conn. Reconnecting...')
this.connectToPeer();
}
@ -276,8 +284,6 @@ export default class AssistManager {
}
private onCallEnd: null | (()=>void) = null;
private onReject: null | (()=>void) = null;
private forceCallEnd() {
this.callConnection?.close();
}
@ -290,33 +296,37 @@ export default class AssistManager {
private initiateCallEnd = () => {
this.forceCallEnd();
this.notifyCallEnd();
this.onCallEnd?.();
this.localCallData && this.localCallData.onCallEnd();
}
private onTrackerCallEnd = () => {
console.log('onTrackerCallEnd')
this.forceCallEnd();
if (getState().calling === CallingState.Requesting) {
this.onReject?.();
this.localCallData && this.localCallData.onReject();
}
this.localCallData && this.localCallData.onCallEnd();
}
private onCallDisconnect = () => {
if (getState().calling === CallingState.True) {
update({ calling: CallingState.Reconnecting });
}
this.onCallEnd?.();
}
private mesagesRecieved: boolean = false;
private disconnectTimeout: ReturnType<typeof setTimeout> | undefined;
private handleCommand(command: string) {
console.log("Data command", command)
switch (command) {
case "unload":
this.onTrackerCallEnd();
this.mesagesRecieved = false;
setTimeout(() => {
if (this.mesagesRecieved) {
return;
}
// @ts-ignore
this.dataConnection?.close();
//this.onTrackerCallEnd();
this.onCallDisconnect()
this.dataConnection?.close();
this.disconnectTimeout = setTimeout(() => {
this.onTrackerCallEnd();
this.setStatus(ConnectionStatus.Disconnected);
}, 8000); // TODO: more convenient way
}, 15000); // TODO: more convenient way
//this.dataConnection?.close();
return;
case "call_end":
@ -337,60 +347,67 @@ export default class AssistManager {
conn.send({ x: Math.round(data.x), y: Math.round(data.y) });
}
private localCallData: {
localStream: MediaStream,
onStream: (s: MediaStream)=>void,
onCallEnd: () => void,
onReject: () => void,
onError?: ()=> void
} | null = null
call(localStream: MediaStream, onStream: (s: MediaStream)=>void, onCallEnd: () => void, onReject: () => void, onError?: ()=> void): null | Function {
if (!this.peer || getState().calling !== CallingState.False) { return null; }
this.localCallData = {
localStream,
onStream,
onCallEnd: () => {
onCallEnd();
this.md.overlay.removeEventListener("mousemove", this.onMouseMove);
update({ calling: CallingState.False });
this.localCallData = null;
},
onReject,
onError,
}
this._call()
return this.initiateCallEnd;
}
private _call() {
if (!this.peer || !this.localCallData || ![CallingState.False, CallingState.Reconnecting].includes(getState().calling)) { return null; }
update({ calling: CallingState.Requesting });
const call = this.peer.call(this.peerID, localStream);
call.on('stream', stream => {
//call.peerConnection.ontrack = (t)=> console.log('ontrack', t)
//console.log('calling...', this.localCallData.localStream)
const call = this.peer.call(this.peerID, this.localCallData.localStream);
call.on('stream', stream => {
update({ calling: CallingState.True });
onStream(stream);
this.localCallData && this.localCallData.onStream(stream);
this.send({
name: store.getState().getIn([ 'user', 'account', 'name']),
});
// @ts-ignore ??
this.md.overlay.addEventListener("mousemove", this.onMouseMove)
});
this.onCallEnd = () => {
onCallEnd();
// @ts-ignore ??
this.md.overlay.removeEventListener("mousemove", this.onMouseMove);
update({ calling: CallingState.False });
this.onCallEnd = null;
}
call.on("close", this.onCallEnd);
call.on("close", this.localCallData.onCallEnd);
call.on("error", (e) => {
console.error("PeerJS error (on call):", e)
this.initiateCallEnd?.();
onError?.();
this.initiateCallEnd();
this.localCallData && this.localCallData.onError && this.localCallData.onError();
});
// const intervalID = setInterval(() => {
// if (!call.open && getState().calling === CallingState.True) {
// this.onCallEnd?.();
// clearInterval(intervalID);
// }
// }, 5000);
window.addEventListener("beforeunload", this.initiateCallEnd)
return this.initiateCallEnd;
}
clear() {
this.initiateCallEnd();
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
if (this.peer) {
this.peer.connections[this.peerID]?.forEach(c => c.open && c.close());
this.peer.disconnect();
this.peer.destroy();
//console.log("destroying peer...")
const peer = this.peer; // otherwise it calls reconnection on data chan close
this.peer = null;
peer.destroy();
}
}
}

View file

@ -3,7 +3,7 @@ import type { Message, SetNodeScroll, CreateElementNode } from '../messages';
import type { TimedMessage } from '../Timed';
import logger from 'App/logger';
import StylesManager from './StylesManager';
import StylesManager, { rewriteNodeStyleSheet } from './StylesManager';
import ListWalker from './ListWalker';
import type { Timed }from '../Timed';
@ -147,6 +147,7 @@ export default class DOMManager extends ListWalker<TimedMessage> {
this.insertNode(msg);
break;
case "create_element_node":
// console.log('elementnode', msg)
if (msg.svg) {
this.nl[ msg.id ] = document.createElementNS('http://www.w3.org/2000/svg', msg.tag);
} else {
@ -207,9 +208,14 @@ export default class DOMManager extends ListWalker<TimedMessage> {
break;
case "set_node_data":
case "set_css_data":
if (!this.nl[ msg.id ]) { logger.error("Node not found", msg); break; }
node = this.nl[ msg.id ]
if (!node) { logger.error("Node not found", msg); break; }
// @ts-ignore
this.nl[ msg.id ].data = msg.data;
node.data = msg.data;
if (node instanceof HTMLStyleElement) {
const doc = this.screen.document
doc && rewriteNodeStyleSheet(doc, node)
}
break;
case "css_insert_rule":
node = this.nl[ msg.id ];
@ -239,6 +245,23 @@ export default class DOMManager extends ListWalker<TimedMessage> {
} catch (e) {
logger.warn(e, msg)
}
break;
case "create_i_frame_document":
// console.log('ifr', msg)
node = this.nl[ msg.frameID ];
if (!(node instanceof HTMLIFrameElement)) {
logger.warn("create_i_frame_document message. Node is not iframe")
return;
}
console.log("iframe", msg)
// await new Promise(resolve => { node.onload = resolve })
const doc = node.contentDocument;
if (!doc) {
logger.warn("No iframe doc", msg, node, node.contentDocument);
return;
}
this.nl[ msg.id ] = doc.documentElement
break;
//not sure what to do with this one
//case "disconnected":

View file

@ -7,6 +7,7 @@ import ListWalker from './ListWalker';
type MouseMoveTimed = MouseMove & Timed;
const HOVER_CLASS = "-openreplay-hover";
const HOVER_CLASS_DEPR = "-asayer-hover";
export default class MouseManager extends ListWalker<MouseMoveTimed> {
private hoverElements: Array<Element> = [];
@ -19,8 +20,14 @@ export default class MouseManager extends ListWalker<MouseMoveTimed> {
const diffAdd = curHoverElements.filter(elem => !this.hoverElements.includes(elem));
const diffRemove = this.hoverElements.filter(elem => !curHoverElements.includes(elem));
this.hoverElements = curHoverElements;
diffAdd.forEach(elem => elem.classList.add(HOVER_CLASS));
diffRemove.forEach(elem => elem.classList.remove(HOVER_CLASS));
diffAdd.forEach(elem => {
elem.classList.add(HOVER_CLASS)
elem.classList.add(HOVER_CLASS_DEPR)
});
diffRemove.forEach(elem => {
elem.classList.remove(HOVER_CLASS)
elem.classList.remove(HOVER_CLASS_DEPR)
});
}
reset(): void {

View file

@ -1,4 +1,3 @@
// @flow
import type StatedScreen from '../StatedScreen';
import type { CssInsertRule, CssDeleteRule } from '../messages';
@ -10,21 +9,35 @@ type TimedCSSRuleMessage = Timed & CSSRuleMessage;
import logger from 'App/logger';
import ListWalker from './ListWalker';
export default class StylesManager extends ListWalker<TimedCSSRuleMessage> {
#screen: StatedScreen;
_linkLoadingCount: number = 0;
_linkLoadPromises: Array<Promise<void>> = [];
_skipCSSLinks: Array<string> = []; // should be common for all pages
constructor(screen: StatedScreen) {
const HOVER_CN = "-openreplay-hover";
const HOVER_SELECTOR = `.${HOVER_CN}`;
// Doesn't work with css files (hasOwnProperty)
export function rewriteNodeStyleSheet(doc: Document, node: HTMLLinkElement | HTMLStyleElement) {
const ss = Object.values(doc.styleSheets).find(s => s.ownerNode === node);
if (!ss || !ss.hasOwnProperty('rules')) { return; }
for(let i = 0; i < ss.rules.length; i++){
const r = ss.rules[i]
if (r instanceof CSSStyleRule) {
r.selectorText = r.selectorText.replace('/\:hover/g', HOVER_SELECTOR)
}
}
}
export default class StylesManager extends ListWalker<TimedCSSRuleMessage> {
private linkLoadingCount: number = 0;
private linkLoadPromises: Array<Promise<void>> = [];
private skipCSSLinks: Array<string> = []; // should be common for all pages
constructor(private readonly screen: StatedScreen) {
super();
this.#screen = screen;
}
reset():void {
super.reset();
this._linkLoadingCount = 0;
this._linkLoadPromises = [];
this.linkLoadingCount = 0;
this.linkLoadPromises = [];
//cancel all promises? tothinkaboutit
}
@ -32,30 +45,34 @@ export default class StylesManager extends ListWalker<TimedCSSRuleMessage> {
setStyleHandlers(node: HTMLLinkElement, value: string): void {
let timeoutId;
const promise = new Promise((resolve) => {
if (this._skipCSSLinks.includes(value)) resolve();
this._linkLoadingCount++;
this.#screen.setCSSLoading(true);
const setSkipAndResolve = () => {
this._skipCSSLinks.push(value); // watch out
resolve();
if (this.skipCSSLinks.includes(value)) resolve(null);
this.linkLoadingCount++;
this.screen.setCSSLoading(true);
const addSkipAndResolve = () => {
this.skipCSSLinks.push(value); // watch out
resolve(null);
}
timeoutId = setTimeout(setSkipAndResolve, 4000);
timeoutId = setTimeout(addSkipAndResolve, 4000);
node.onload = resolve;
node.onerror = setSkipAndResolve;
node.onload = () => {
const doc = this.screen.document;
doc && rewriteNodeStyleSheet(doc, node);
resolve(null);
}
node.onerror = addSkipAndResolve;
}).then(() => {
node.onload = null;
node.onerror = null;
clearTimeout(timeoutId);
this._linkLoadingCount--;
if (this._linkLoadingCount === 0) {
this.#screen.setCSSLoading(false);
this.linkLoadingCount--;
if (this.linkLoadingCount === 0) {
this.screen.setCSSLoading(false);
}
});
this._linkLoadPromises.push(promise);
this.linkLoadPromises.push(promise);
}
#manageRule = (msg: CSSRuleMessage):void => {
private manageRule = (msg: CSSRuleMessage):void => {
// if (msg.tp === "css_insert_rule") {
// let styleSheet = this.#screen.document.styleSheets[ msg.stylesheetID ];
// if (!styleSheet) {
@ -86,7 +103,7 @@ export default class StylesManager extends ListWalker<TimedCSSRuleMessage> {
}
moveReady(t: number): Promise<void> {
return Promise.all(this._linkLoadPromises)
.then(() => this.moveApply(t, this.#manageRule));
return Promise.all(this.linkLoadPromises)
.then(() => this.moveApply(t, this.manageRule));
}
}

View file

@ -57,11 +57,12 @@ export default class WindowNodeCounter {
addNode(id: number, parentID: number) {
if (!this._nodes[ parentID ]) {
console.error(`Wrong! Node with id ${ parentID } (parentId) not found.`);
//TODO: iframe case
//console.error(`Wrong! Node with id ${ parentID } (parentId) not found.`);
return;
}
if (!!this._nodes[ id ]) {
console.error(`Wrong! Node with id ${ id } already exists.`);
//console.error(`Wrong! Node with id ${ id } already exists.`);
return;
}
this._nodes[id] = this._nodes[ parentID ].newChild();

View file

@ -36,6 +36,8 @@ export const ID_TP_MAP = {
54: "connection_information",
55: "set_page_visibility",
59: "long_task",
69: "mouse_click",
70: "create_i_frame_document",
} as const;
@ -255,11 +257,26 @@ export interface LongTask {
containerName: string,
}
export interface MouseClick {
tp: "mouse_click",
id: number,
hesitationTime: number,
label: string,
selector: string,
}
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask;
export interface CreateIFrameDocument {
tp: "create_i_frame_document",
frameID: number,
id: number,
}
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask | MouseClick | CreateIFrameDocument;
export default function (r: PrimitiveReader): Message | null {
switch (r.readUint()) {
const ui= r.readUint()
switch (ui) {
case 0:
return {
@ -509,7 +526,24 @@ export default function (r: PrimitiveReader): Message | null {
containerName: r.readString(),
};
case 69:
return {
tp: ID_TP_MAP[69],
id: r.readUint(),
hesitationTime: r.readUint(),
label: r.readString(),
selector: r.readString(),
};
case 70:
return {
tp: ID_TP_MAP[70],
frameID: r.readUint(),
id: r.readUint(),
};
default:
console.log("wtf is this", ui)
r.readUint(); // IOS skip timestamp
r.skip(r.readUint());
return null;

View file

@ -54,8 +54,6 @@ env:
sessions_bucket: mobs
sourcemaps_bucket: sourcemaps
js_cache_bucket: sessions-assets
sourcemaps_reader: 'http://utilities-openreplay.app.svc.cluster.local:9000/assist/sourcemaps'
peers: 'http://utilities-openreplay.app.svc.cluster.local:9000/assist/peers'
# Enable logging for python app
# Ref: https://stackoverflow.com/questions/43969743/logs-in-kubernetes-pod-not-showing-up
PYTHONUNBUFFERED: '0'

View file

@ -0,0 +1,10 @@
BEGIN;
CREATE INDEX pages_session_id_timestamp_idx ON events.pages (session_id, timestamp);
CREATE INDEX projects_tenant_id_idx ON projects(tenant_id);
CREATE INDEX webhooks_tenant_id_idx ON webhooks(tenant_id);
CREATE INDEX issues_project_id_idx ON issues(project_id);
CREATE INDEX jobs_project_id_idx ON jobs(project_id);
COMMIT;

View file

@ -172,6 +172,7 @@ CREATE TABLE projects
"defaultInputMode": "plain"
}'::jsonb -- ??????
);
CREATE INDEX projects_tenant_id_idx ON projects(tenant_id);
CREATE OR REPLACE FUNCTION notify_project() RETURNS trigger AS
$$
@ -247,7 +248,7 @@ create table webhooks
index integer default 0 not null,
name varchar(100)
);
CREATE INDEX webhooks_tenant_id_idx ON webhooks(tenant_id);
-- --- notifications.sql ---
@ -387,6 +388,7 @@ CREATE TABLE issues
);
CREATE INDEX ON issues (issue_id, type);
CREATE INDEX issues_context_string_gin_idx ON public.issues USING GIN (context_string gin_trgm_ops);
CREATE INDEX issues_project_id_idx ON issues(project_id);
-- --- errors.sql ---
@ -870,5 +872,6 @@ CREATE TABLE jobs
);
CREATE INDEX ON jobs (status);
CREATE INDEX ON jobs (start_at);
CREATE INDEX jobs_project_id_idx ON jobs(project_id);
COMMIT;

View file

@ -8,7 +8,9 @@
"module": true,
"console": true,
"Promise": true,
"Buffer": true
"Buffer": true,
"URL": true,
"global": true
},
"plugins": [
"prettier"

View file

@ -10,28 +10,31 @@ npm i -D @openreplay/sourcemap-uploader
## CLI
Upload sourcemap for one file:
### Upload a sourcemap for one file:
```
sourcemap-uploader -s https://opnereplay.mycompany.com/api -k API_KEY -p PROJECT_KEY file -m ./dist/index.js.map -u https://myapp.com/index.js
```
Upload all sourcemaps in a given directory. The URL must correspond to the root where you upload JS files from the directory. In other words, if you have your `app-42.js` along with the `app-42.js.map` in the `./build` folder and then want to upload it to your OpenReplay instance so it can be reachable through the link `https://myapp.com/static/app-42.js`, then the command should be like:
### Upload all sourcemaps in a given directory.
The URL must correspond to the root where you upload JS files from the directory. In other words, if you have your `app-42.js` along with the `app-42.js.map` in the `./build` folder and then want to upload it to your OpenReplay instance so it can be reachable through the link `https://myapp.com/static/app-42.js`, then the command should be like:
```
sourcemap-uploader -s https://opnereplay.mycompany.com/api -k API_KEY -p PROJECT_KEY dir -m ./build -u https://myapp.com/static
```
- Use `-s` (`--server`) to specify the URL of your OpenReplay instance (make to append it with /api)
- Use `-s` (`--server`) to specify the URL of your OpenReplay instance (append it with /api).
**Do not use this parameter if you use SaaS version of the OpenRplay**
- Use `-v` (`--verbose`) to see the logs.
## NPM
There are two functions inside `index.js` of the package:
There are two functions you can export from the package:
```
uploadFile(api_key, project_key, sourcemap_file_path, js_file_url)
uploadDir(api_key, project_key, sourcemap_dir_path, js_dir_url)
uploadFile(api_key, project_key, sourcemap_file_path, js_file_url, [server])
uploadDir(api_key, project_key, sourcemap_dir_path, js_dir_url, [server])
```
Both functions return Promise.
Both functions return Promise with a result value to be the list of files for which sourcemaps were uploaded.

View file

@ -14,7 +14,8 @@ parser.addArgument(['-k', '--api-key'], {
help: 'API key',
required: true,
});
parser.addArgument(['-p', '-i', '--project-key'], { // -i is depricated
parser.addArgument(['-p', '-i', '--project-key'], {
// -i is depricated
help: 'Project Key',
required: true,
});
@ -54,25 +55,34 @@ dir.addArgument(['-u', '--js-dir-url'], {
// TODO: exclude in dir
const { command, api_key, project_key, server, verbose, ...args } = parser.parseArgs();
const { command, api_key, project_key, server, verbose, ...args } =
parser.parseArgs();
global._VERBOSE = !!verbose;
try {
global.SERVER = new URL(server || "https://api.openreplay.com");
} catch (e) {
console.error(`Sourcemap Uploader: server URL parse error. ${e}`)
}
(command === 'file'
? uploadFile(api_key, project_key, args.sourcemap_file_path, args.js_file_url)
: uploadDir(api_key, project_key, args.sourcemap_dir_path, args.js_dir_url)
? uploadFile(
api_key,
project_key,
args.sourcemap_file_path,
args.js_file_url,
server,
)
: uploadDir(
api_key,
project_key,
args.sourcemap_dir_path,
args.js_dir_url,
server,
)
)
.then((sourceFiles) =>
sourceFiles.length > 0
? console.log(`Successfully uploaded ${sourceFiles.length} sourcemap file${sourceFiles.length > 1 ? "s" : ""} for: \n`
+ sourceFiles.join("\t\n")
.then((sourceFiles) =>
sourceFiles.length > 0
? console.log(
`Successfully uploaded ${sourceFiles.length} sourcemap file${
sourceFiles.length > 1 ? 's' : ''
} for: \n` + sourceFiles.join('\t\n'),
)
: console.log(`No sourcemaps found in ${args.sourcemap_dir_path}`),
)
: console.log(`No sourcemaps found in ${ args.sourcemap_dir_path }`)
)
.catch(e => console.error(`Sourcemap Uploader: ${e}`));
.catch((e) => console.error(`Sourcemap Uploader: ${e}`));

View file

@ -3,12 +3,24 @@ const readFile = require('./lib/readFile.js'),
uploadSourcemaps = require('./lib/uploadSourcemaps.js');
module.exports = {
async uploadFile(api_key, project_key, sourcemap_file_path, js_file_url) {
async uploadFile(
api_key,
project_key,
sourcemap_file_path,
js_file_url,
server,
) {
const sourcemap = await readFile(sourcemap_file_path, js_file_url);
return uploadSourcemaps(api_key, project_key, [sourcemap]);
return uploadSourcemaps(api_key, project_key, [sourcemap], server);
},
async uploadDir(api_key, project_key, sourcemap_dir_path, js_dir_url) {
async uploadDir(
api_key,
project_key,
sourcemap_dir_path,
js_dir_url,
server,
) {
const sourcemaps = await readDir(sourcemap_dir_path, js_dir_url);
return uploadSourcemaps(api_key, project_key, sourcemaps);
return uploadSourcemaps(api_key, project_key, sourcemaps, server);
},
};

View file

@ -3,12 +3,13 @@ const readFile = require('./readFile');
module.exports = (sourcemap_dir_path, js_dir_url) => {
sourcemap_dir_path = (sourcemap_dir_path + '/').replace(/\/+/g, '/');
if (js_dir_url[ js_dir_url.length - 1 ] !== '/') { // replace will break schema
if (js_dir_url[js_dir_url.length - 1] !== '/') {
// replace will break schema
js_dir_url += '/';
}
return glob(sourcemap_dir_path + '**/*.map').then(sourcemap_file_paths =>
return glob(sourcemap_dir_path + '**/*.map').then((sourcemap_file_paths) =>
Promise.all(
sourcemap_file_paths.map(sourcemap_file_path =>
sourcemap_file_paths.map((sourcemap_file_path) =>
readFile(
sourcemap_file_path,
js_dir_url + sourcemap_file_path.slice(sourcemap_dir_path.length, -4),

View file

@ -1,6 +1,6 @@
const fs = require('fs').promises;
module.exports = (sourcemap_file_path, js_file_url) =>
fs.readFile(sourcemap_file_path, 'utf8').then(body => {
fs.readFile(sourcemap_file_path, 'utf8').then((body) => {
return { sourcemap_file_path, js_file_url, body };
});

View file

@ -1,44 +1,55 @@
const https = require('https');
const getUploadURLs = (api_key, project_key, js_file_urls) =>
const getUploadURLs = (api_key, project_key, js_file_urls, server) =>
new Promise((resolve, reject) => {
if (js_file_urls.length === 0) {
resolve([]);
}
const pathPrefix = (global.SERVER.pathname + "/").replace(/\/+/g, '/');
let serverURL;
try {
serverURL = new URL(server);
} catch (e) {
return reject(`Failed to parse server URL "${server}".`);
}
const pathPrefix = (serverURL.pathname + '/').replace(/\/+/g, '/');
const options = {
method: 'PUT',
hostname: global.SERVER.host,
hostname: serverURL.host,
path: pathPrefix + `${project_key}/sourcemaps/`,
headers: { Authorization: api_key, 'Content-Type': 'application/json' },
}
};
if (global._VERBOSE) {
console.log("Request: ", options, "\nFiles: ", js_file_urls);
console.log('Request: ', options, '\nFiles: ', js_file_urls);
}
const req = https.request(
options,
res => {
const req = https.request(options, (res) => {
if (global._VERBOSE) {
console.log(
'Response Code: ',
res.statusCode,
'\nMessage: ',
res.statusMessage,
);
}
if (res.statusCode === 403) {
reject(
'Authorisation rejected. Please, check your API_KEY and/or PROJECT_KEY.',
);
return;
} else if (res.statusCode !== 200) {
reject('Server Error. Please, contact OpenReplay support.');
return;
}
let data = '';
res.on('data', (s) => (data += s));
res.on('end', () => {
if (global._VERBOSE) {
console.log("Response Code: ", res.statusCode, "\nMessage: ", res.statusMessage);
console.log('Server Response: ', data);
}
if (res.statusCode === 403) {
reject("Authorisation rejected. Please, check your API_KEY and/or PROJECT_KEY.")
return
} else if (res.statusCode !== 200) {
reject("Server Error. Please, contact OpenReplay support.");
return;
}
let data = '';
res.on('data', s => (data += s));
res.on('end', () => {
if (global._VERBOSE) {
console.log("Server Response: ", data)
}
resolve(JSON.parse(data).data)
});
},
);
resolve(JSON.parse(data).data);
});
});
req.on('error', reject);
req.write(JSON.stringify({ URL: js_file_urls }));
req.end();
@ -56,14 +67,19 @@ const uploadSourcemap = (upload_url, body) =>
'Content-Type': 'application/json',
},
},
res => {
(res) => {
if (res.statusCode !== 200) {
if (global._VERBOSE) {
console.log("Response Code: ", res.statusCode, "\nMessage: ", res.statusMessage);
console.log(
'Response Code: ',
res.statusCode,
'\nMessage: ',
res.statusMessage,
);
}
reject("Unable to upload. Please, contact OpenReplay support.");
return; // TODO: report per-file errors.
reject('Unable to upload. Please, contact OpenReplay support.');
return; // TODO: report per-file errors.
}
resolve();
//res.on('end', resolve);
@ -74,16 +90,18 @@ const uploadSourcemap = (upload_url, body) =>
req.end();
});
module.exports = (api_key, project_key, sourcemaps) =>
module.exports = (api_key, project_key, sourcemaps, server) =>
getUploadURLs(
api_key,
project_key,
sourcemaps.map(({ js_file_url }) => js_file_url),
).then(upload_urls =>
server || 'https://api.openreplay.com',
).then((upload_urls) =>
Promise.all(
upload_urls.map((upload_url, i) =>
uploadSourcemap(upload_url, sourcemaps[i].body)
.then(() => sourcemaps[i].js_file_url)
uploadSourcemap(upload_url, sourcemaps[i].body).then(
() => sourcemaps[i].js_file_url,
),
),
),
);

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "@openreplay/sourcemap-uploader",
"version": "3.0.5",
"version": "3.0.6",
"description": "NPM module to upload your JS sourcemaps files to OpenReplay",
"bin": "cli.js",
"main": "index.js",
@ -13,10 +13,10 @@
],
"license": "MIT",
"devDependencies": {
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-prettier": "^3.1.2",
"prettier": "^1.19.1"
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.4.1"
},
"dependencies": {
"argparse": "^1.0.10",

View file

@ -1,6 +1,6 @@
{
"name": "@openreplay/tracker-assist",
"version": "3.0.4",
"version": "3.1.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -30,6 +30,12 @@
"js-tokens": "^4.0.0"
}
},
"@medv/finder": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@medv/finder/-/finder-2.1.0.tgz",
"integrity": "sha512-Egrg5XO4kLol24b1Kv50HDfi5hW0yQ6aWSsO0Hea1eJ4rogKElIN0M86FdVnGF4XIGYyA7QWx0MgbOzVPA0qkA==",
"dev": true
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -57,11 +63,12 @@
}
},
"@openreplay/tracker": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@openreplay/tracker/-/tracker-3.0.5.tgz",
"integrity": "sha512-hIY7DnQmm7bCe6v+e257WD7OdNuBOWUZ15Q3yUEdyxu7xDNG7brbak9pS97qCt3VY9xGK0RvW/j3ANlRPk8aVg==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@openreplay/tracker/-/tracker-3.3.0.tgz",
"integrity": "sha512-g9sOG01VaiRLw4TcUbux8j3moa7gsGs8rjZPEVJ5SJqxjje9R7tpUD5UId9ne7QdHSoiHfrWFk3TNRLpXyvImg==",
"dev": true,
"requires": {
"@medv/finder": "^2.0.0",
"error-stack-parser": "^2.0.6"
}
},

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker-assist",
"description": "Tracker plugin for screen assistance through the WebRTC",
"version": "3.0.4",
"version": "3.2.0",
"keywords": [
"WebRTC",
"assistance",
@ -24,10 +24,10 @@
"peerjs": "^1.3.2"
},
"peerDependencies": {
"@openreplay/tracker": "^3.1.0"
"@openreplay/tracker": "^3.4.0"
},
"devDependencies": {
"@openreplay/tracker": "^3.0.5",
"@openreplay/tracker": "^3.4.0",
"prettier": "^1.18.2",
"replace-in-files-cli": "^1.0.0",
"typescript": "^3.6.4"

View file

@ -7,6 +7,7 @@ export default class CallWindow {
private audioBtn: HTMLAnchorElement | null = null;
private videoBtn: HTMLAnchorElement | null = null;
private userNameSpan: HTMLSpanElement | null = null;
private vPlaceholder: HTMLParagraphElement | null = null;
private tsInterval: ReturnType<typeof setInterval>;
constructor(endCall: () => void) {
@ -23,84 +24,84 @@ export default class CallWindow {
height: "200px",
width: "200px",
});
//iframe.src = "//static.openreplay.com/tracker-assist/index.html";
iframe.onload = () => {
const doc = iframe.contentDocument;
if (!doc) {
console.error("OpenReplay: CallWindow iframe document is not reachable.")
return;
}
fetch("https://static.openreplay.com/tracker-assist/index.html")
//fetch("file:///Users/shikhu/work/asayer-tester/dist/assist/index.html")
.then(r => r.text())
.then((text) => {
iframe.onload = () => {
doc.body.removeChild(doc.body.children[0]); //?!!>R#
const assistSection = doc.getElementById("or-assist")
assistSection && assistSection.removeAttribute("style");
iframe.style.height = doc.body.scrollHeight + 'px';
iframe.style.width = doc.body.scrollWidth + 'px';
iframe.onload = null;
}
text = text.replace(/href="css/g, "href=\"https://static.openreplay.com/tracker-assist/css")
doc.open();
doc.write(text);
doc.close();
this.vLocal = doc.getElementById("video-local") as HTMLVideoElement;
this.vRemote = doc.getElementById("video-remote") as HTMLVideoElement;
this._trySetStreams();
//
this.vLocal.parentElement && this.vLocal.parentElement.classList.add("d-none");
this.audioBtn = doc.getElementById("audio-btn") as HTMLAnchorElement;
this.audioBtn.onclick = () => this.toggleAudio();
this.videoBtn = doc.getElementById("video-btn") as HTMLAnchorElement;
this.videoBtn.onclick = () => this.toggleVideo();
this.userNameSpan = doc.getElementById("username") as HTMLSpanElement;
this._trySetAssistentName();
const endCallBtn = doc.getElementById("end-call-btn") as HTMLAnchorElement;
endCallBtn.onclick = endCall;
const tsText = doc.getElementById("time-stamp");
const startTs = Date.now();
if (tsText) {
this.tsInterval = setInterval(() => {
const ellapsed = Date.now() - startTs;
const secsFull = ~~(ellapsed / 1000);
const mins = ~~(secsFull / 60);
const secs = secsFull - mins * 60
tsText.innerText = `${mins}:${secs < 10 ? 0 : ''}${secs}`;
}, 500);
}
// TODO: better D'n'D
doc.body.setAttribute("draggable", "true");
doc.body.ondragstart = (e) => {
if (!e.dataTransfer || !e.target) { return; }
//@ts-ignore
if (!e.target.classList || !e.target.classList.contains("card-header")) { return; }
e.dataTransfer.setDragImage(doc.body, e.clientX, e.clientY);
};
doc.body.ondragend = e => {
Object.assign(iframe.style, {
left: `${e.clientX}px`,
top: `${e.clientY}px`,
bottom: 'auto',
right: 'auto',
})
}
});
}
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) {
console.error("OpenReplay: CallWindow iframe document is not reachable.")
return;
}
fetch("https://static.openreplay.com/tracker-assist/index.html")
//fetch("file:///Users/shikhu/work/asayer-tester/dist/assist/index.html")
.then(r => r.text())
.then((text) => {
iframe.onload = () => {
doc.body.removeChild(doc.body.children[0]); //?!!>R#
const assistSection = doc.getElementById("or-assist")
assistSection && assistSection.removeAttribute("style");
iframe.style.height = doc.body.scrollHeight + 'px';
iframe.style.width = doc.body.scrollWidth + 'px';
iframe.onload = null;
}
text = text.replace(/href="css/g, "href=\"https://static.openreplay.com/tracker-assist/css")
doc.open();
doc.write(text);
doc.close();
this.vLocal = doc.getElementById("video-local") as HTMLVideoElement;
this.vRemote = doc.getElementById("video-remote") as HTMLVideoElement;
//
this.vLocal.parentElement && this.vLocal.parentElement.classList.add("d-none");
this.audioBtn = doc.getElementById("audio-btn") as HTMLAnchorElement;
this.audioBtn.onclick = () => this.toggleAudio();
this.videoBtn = doc.getElementById("video-btn") as HTMLAnchorElement;
this.videoBtn.onclick = () => this.toggleVideo();
this.userNameSpan = doc.getElementById("username") as HTMLSpanElement;
this.vPlaceholder = doc.querySelector("#remote-stream p")
this._trySetAssistentName();
this._trySetStreams();
const endCallBtn = doc.getElementById("end-call-btn") as HTMLAnchorElement;
endCallBtn.onclick = endCall;
const tsText = doc.getElementById("time-stamp");
const startTs = Date.now();
if (tsText) {
this.tsInterval = setInterval(() => {
const ellapsed = Date.now() - startTs;
const secsFull = ~~(ellapsed / 1000);
const mins = ~~(secsFull / 60);
const secs = secsFull - mins * 60
tsText.innerText = `${mins}:${secs < 10 ? 0 : ''}${secs}`;
}, 500);
}
// TODO: better D'n'D
doc.body.setAttribute("draggable", "true");
doc.body.ondragstart = (e) => {
if (!e.dataTransfer || !e.target) { return; }
//@ts-ignore
if (!e.target.classList || !e.target.classList.contains("card-header")) { return; }
e.dataTransfer.setDragImage(doc.body, e.clientX, e.clientY);
};
doc.body.ondragend = e => {
Object.assign(iframe.style, {
left: `${e.clientX}px`, // TODO: fix in case e is inside the iframe
top: `${e.clientY}px`,
bottom: 'auto',
right: 'auto',
})
}
});
}
// TODO: load(): Promise
private aRemote: HTMLAudioElement | null = null;
private localStream: MediaStream | null = null;
private remoteStream: MediaStream | null = null;
@ -109,7 +110,11 @@ export default class CallWindow {
private _trySetStreams() {
if (this.vRemote && !this.vRemote.srcObject && this.remoteStream) {
this.vRemote.srcObject = this.remoteStream;
// Hack for audio (doesen't work in iframe because of some magical reasons)
if (this.vPlaceholder) {
this.vPlaceholder.innerText = "Video has been paused. Click anywhere to resume.";
}
// Hack for audio (doesen't work in iframe because of some magical reasons (check if it is connected to autoplay?))
this.aRemote = document.createElement("audio");
this.aRemote.autoplay = true;
this.aRemote.style.display = "none"
@ -133,6 +138,10 @@ export default class CallWindow {
this._trySetStreams();
}
playRemote() {
this.vRemote && this.vRemote.play()
}
// TODO: determined workflow
_trySetAssistentName() {

View file

@ -1,7 +1,7 @@
const declineIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="22" width="22" viewBox="0 0 128 128" ><g id="Circle_Grid" data-name="Circle Grid"><circle cx="64" cy="64" fill="#ef5261" r="64"/></g><g id="icon"><path d="m57.831 70.1c8.79 8.79 17.405 12.356 20.508 9.253l4.261-4.26a7.516 7.516 0 0 1 10.629 0l9.566 9.566a7.516 7.516 0 0 1 0 10.629l-7.453 7.453c-7.042 7.042-27.87-2.358-47.832-22.319-9.976-9.981-16.519-19.382-20.748-28.222s-5.086-16.091-1.567-19.61l7.453-7.453a7.516 7.516 0 0 1 10.629 0l9.566 9.563a7.516 7.516 0 0 1 0 10.629l-4.264 4.271c-3.103 3.1.462 11.714 9.252 20.5z" fill="#eeefee"/></g></svg>`;
export default class Confirm {
export default class ConfirmWindow {
private wrapper: HTMLDivElement;
constructor(text: string, styles?: Object) {
@ -67,26 +67,32 @@ export default class Confirm {
this.wrapper = wrapper;
answerBtn.onclick = () => {
this.remove();
this.callback(true);
this._remove();
this.resolve(true);
}
declineBtn.onclick = () => {
this.remove();
this.callback(false);
this._remove();
this.resolve(false);
}
}
mount() {
private resolve: (result: boolean) => void = ()=>{};
private reject: ()=>void = ()=>{};
mount(): Promise<boolean> {
document.body.appendChild(this.wrapper);
return new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
private callback: (result: boolean) => void = ()=>{};
onAnswer(callback: (result: boolean) => void) {
this.callback = callback;
}
remove() {
private _remove() {
if (!this.wrapper.parentElement) { return; }
document.body.removeChild(this.wrapper);
}
remove() {
this._remove();
this.reject();
}
}

View file

@ -0,0 +1,8 @@
/**
* Hach for the issue of peerjs compilation on angular
* Mor info here: https://github.com/peers/peerjs/issues/552
*/
// @ts-ignore
window.parcelRequire = window.parcelRequire || undefined;

View file

@ -1,3 +1,4 @@
import './_slim';
import Peer, { MediaConnection } from 'peerjs';
import type { DataConnection } from 'peerjs';
import { App, Messages } from '@openreplay/tracker';
@ -5,12 +6,13 @@ import type Message from '@openreplay/tracker';
import Mouse from './Mouse';
import CallWindow from './CallWindow';
import Confirm from './Confirm';
import ConfirmWindow from './ConfirmWindow';
export interface Options {
confirmText: string,
confirmStyle: Object, // Styles object
session_calling_peer_key: string,
}
@ -25,6 +27,7 @@ export default function(opts: Partial<Options> = {}) {
{
confirmText: "You have a call. Do you want to answer?",
confirmStyle: {},
session_calling_peer_key: "__openreplay_calling_peer",
},
opts,
);
@ -34,20 +37,28 @@ export default function(opts: Partial<Options> = {}) {
return;
}
let assistDemandedRestart = false;
let peer : Peer | null = null;
app.attachStopCallback(function() {
if (assistDemandedRestart) { return; }
peer && peer.destroy();
});
app.attachStartCallback(function() {
// @ts-ignore
if (assistDemandedRestart) { return; }
const peerID = `${app.projectKey}-${app.getSessionID()}`
const peer = new Peer(peerID, {
peer = new Peer(peerID, {
// @ts-ignore
host: app.getHost(),
path: '/assist',
port: location.protocol === 'http:' && appOptions.__DISABLE_SECURE_MODE ? 80 : 443,
});
console.log('OpenReplay tracker-assist peerID:', peerID)
peer.on('error', e => console.log("OpenReplay tracker-assist peer error: ", e.type, e))
peer.on('connection', function(conn) {
window.addEventListener("beforeunload", () => conn.open && conn.send("unload"));
peer.on('error', e => console.log("OpenReplay tracker-assist peer error: ", e.type, e))
console.log('OpenReplay tracker-assist: Connecting...')
conn.on('open', function() {
@ -66,9 +77,12 @@ export default function(opts: Partial<Options> = {}) {
buffering = false;
}
}
assistDemandedRestart = true;
app.stop();
//@ts-ignore (should update tracker dependency)
app.addCommitCallback((messages: Array<Message>): void => {
if (!conn.open) { return; } // TODO: clear commit callbacks on connection close
let i = 0;
while (i < messages.length) {
buffer.push(messages.slice(i, i+=1000));
@ -78,39 +92,57 @@ export default function(opts: Partial<Options> = {}) {
sendNext();
}
});
app.start();
app.start().then(() => { assistDemandedRestart = false; });
});
});
let calling: CallingState = CallingState.False;
let callingState: CallingState = CallingState.False;
peer.on('call', function(call) {
if (!peer) { return; }
const dataConn: DataConnection | undefined = peer
.connections[call.peer].find(c => c.type === 'data');
if (calling !== CallingState.False || !dataConn) {
if (callingState !== CallingState.False || !dataConn) {
call.close();
return;
}
calling = CallingState.Requesting;
function setCallingState(newState: CallingState) {
if (newState === CallingState.True) {
sessionStorage.setItem(options.session_calling_peer_key, call.peer);
} else if (newState === CallingState.False) {
sessionStorage.removeItem(options.session_calling_peer_key);
}
callingState = newState;
}
const notifyCallEnd = () => {
dataConn.open && dataConn.send("call_end");
}
const confirm = new Confirm(options.confirmText, options.confirmStyle);
dataConn.on('data', (data) => { // if call closed by a caller before confirm
if (data === "call_end") {
//console.log('OpenReplay tracker-assist: receiving callend onconfirm')
calling = CallingState.False;
confirm.remove();
}
});
confirm.mount();
confirm.onAnswer(agreed => {
let confirmAnswer: Promise<boolean>
const peerOnCall = sessionStorage.getItem(options.session_calling_peer_key)
if (peerOnCall === call.peer) {
confirmAnswer = Promise.resolve(true)
} else {
setCallingState(CallingState.Requesting);
const confirm = new ConfirmWindow(options.confirmText, options.confirmStyle);
confirmAnswer = confirm.mount();
dataConn.on('data', (data) => { // if call closed by a caller before confirm
if (data === "call_end") {
//console.log('OpenReplay tracker-assist: receiving callend onconfirm')
setCallingState(CallingState.False);
confirm.remove();
}
});
}
confirmAnswer.then(agreed => {
if (!agreed || !dataConn.open) {
call.close();
notifyCallEnd();
calling = CallingState.False;
setCallingState(CallingState.False);
return;
}
@ -119,11 +151,10 @@ export default function(opts: Partial<Options> = {}) {
const onCallConnect = lStream => {
const onCallEnd = () => {
//console.log("on callend", call.open)
mouse.remove();
callUI?.remove();
lStream.getTracks().forEach(t => t.stop());
calling = CallingState.False;
setCallingState(CallingState.False);
}
const initiateCallEnd = () => {
//console.log("callend initiated")
@ -133,6 +164,7 @@ export default function(opts: Partial<Options> = {}) {
}
call.answer(lStream);
setCallingState(CallingState.True)
dataConn.on("close", onCallEnd);
@ -164,6 +196,11 @@ export default function(opts: Partial<Options> = {}) {
});
call.on('stream', function(rStream) {
callUI.setRemoteStream(rStream);
const onInteraction = () => {
callUI.playRemote()
document.removeEventListener("click", onInteraction)
}
document.addEventListener("click", onInteraction)
});
dataConn.on('data', (data: any) => {
if (data === "call_end") {
@ -188,7 +225,7 @@ export default function(opts: Partial<Options> = {}) {
.then(onCallConnect)
.catch(e => console.log("OpenReplay tracker-assist: cant reach media devices. ", e));
});
});
}).catch(); // in case of Confirm.remove() without any confirmation
});
});
}

View file

@ -1,6 +1,6 @@
{
"name": "@openreplay/tracker-axios",
"version": "3.0.0",
"version": "3.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -99,12 +99,12 @@
"dev": true
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dev": true,
"requires": {
"follow-redirects": "^1.10.0"
"follow-redirects": "^1.14.0"
}
},
"braces": {
@ -267,9 +267,9 @@
}
},
"follow-redirects": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==",
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
"dev": true
},
"function-bind": {
@ -761,9 +761,9 @@
}
},
"trim-newlines": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz",
"integrity": "sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
"integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
"dev": true
},
"type-fest": {

View file

@ -21,11 +21,11 @@
"dependencies": {},
"peerDependencies": {
"@openreplay/tracker": "^3.0.0",
"axios": "^0.21.1"
"axios": "^0.21.2"
},
"devDependencies": {
"@openreplay/tracker": "^3.0.0",
"axios": "^0.21.1",
"axios": "^0.21.2",
"prettier": "^1.18.2",
"replace-in-files-cli": "^1.0.0",
"typescript": "^3.6.4"

View file

@ -264,9 +264,9 @@
"dev": true
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
"integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
@ -545,9 +545,9 @@
"dev": true
},
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"path-type": {
@ -752,9 +752,9 @@
}
},
"trim-newlines": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz",
"integrity": "sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
"integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
"dev": true
},
"type-fest": {

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "3.2.1",
"version": "3.4.0",
"keywords": [
"logging",
"replay"
@ -30,9 +30,6 @@
"@typescript-eslint/parser": "^2.34.0",
"eslint": "^6.8.0",
"eslint-plugin-prettier": "^3.1.4",
"gulp": "^4.0.2",
"gulp-typescript": "^6.0.0-alpha.1",
"merge2": "^1.4.1",
"prettier": "^2.0.0",
"replace-in-files": "^2.0.3",
"rollup": "^2.17.0",

View file

@ -8,5 +8,5 @@ export default {
file: 'build/webworker.js',
format: 'cjs',
},
plugins: [resolve(), babel({ babelHelpers: 'bundled' }), terser()],
plugins: [resolve(), babel({ babelHelpers: 'bundled' }), terser({ mangle: { reserved: ['$'] } })],
};

View file

@ -1,4 +1,4 @@
import { timestamp, log } from '../utils';
import { timestamp, log, warn } from '../utils';
import { Timestamp, TechnicalInfo, PageClose } from '../../messages';
import Message from '../../messages/message';
import Nodes from './nodes';
@ -11,6 +11,12 @@ import type { Options as ObserverOptions } from './observer';
import type { Options as WebworkerOptions, WorkerMessageData } from '../../messages/webworker';
interface OnStartInfo {
sessionID: string,
sessionToken: string,
userUUID: string,
}
export type Options = {
revID: string;
node_id: string;
@ -18,14 +24,19 @@ export type Options = {
session_pageno_key: string;
local_uuid_key: string;
ingestPoint: string;
resourceBaseHref: string, // resourceHref?
//resourceURLRewriter: (url: string) => string | boolean,
__is_snippet: boolean;
onStart?: (info: { sessionID: string, sessionToken: string, userUUID: string }) => void;
__debug_report_edp: string | null;
onStart?: (info: OnStartInfo) => void;
} & ObserverOptions & WebworkerOptions;
type Callback = () => void;
type CommitCallback = (messages: Array<Message>) => void;
export const DEFAULT_INGEST_POINT = 'https://ingest.openreplay.com';
// TODO: use backendHost only
export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest';
export default class App {
readonly nodes: Nodes;
@ -56,9 +67,12 @@ export default class App {
session_pageno_key: '__openreplay_pageno',
local_uuid_key: '__openreplay_uuid',
ingestPoint: DEFAULT_INGEST_POINT,
resourceBaseHref: '',
__is_snippet: false,
__debug_report_edp: null,
obscureTextEmails: true,
obscureTextNumbers: false,
captureIFrames: false,
},
opts,
);
@ -99,8 +113,24 @@ export default class App {
this.attachEventListener(window, 'beforeunload', alertWorker, false);
this.attachEventListener(document, 'mouseleave', alertWorker, false, false);
this.attachEventListener(document, 'visibilitychange', alertWorker, false);
} catch (e) { /* TODO: send report */}
} catch (e) {
this.sendDebugReport("worker_start", e);
}
}
private sendDebugReport(context: string, e: any) {
if(this.options.__debug_report_edp !== null) {
fetch(this.options.__debug_report_edp, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context,
error: `${e}`
})
});
}
}
send(message: Message, urgent = false): void {
if (!this.isActive) {
return;
@ -174,22 +204,37 @@ export default class App {
return this._sessionID || undefined;
}
getHost(): string {
return new URL(this.options.ingestPoint).host;
return new URL(this.options.ingestPoint).hostname
}
getProjectKey(): string {
return this.projectKey
}
getBaseHref(): string {
if (this.options.resourceBaseHref) {
return this.options.resourceBaseHref
}
if (document.baseURI) {
return document.baseURI
}
// IE only
return document.head
?.getElementsByTagName("base")[0]
?.getAttribute("href") || location.origin + location.pathname
}
isServiceURL(url: string): boolean {
return url.startsWith(this.options.ingestPoint);
return url.startsWith(this.options.ingestPoint)
}
active(): boolean {
return this.isActive;
}
_start(reset: boolean): void { // TODO: return a promise instead of onStart handling
private _start(reset: boolean): Promise<OnStartInfo> {
if (!this.isActive) {
this.isActive = true;
if (!this.worker) {
throw new Error("Stranger things: no worker found");
return Promise.reject("No worker found: perhaps, CSP is not set.");
}
this.isActive = true;
let pageNo: number = 0;
const pageNoStr = sessionStorage.getItem(this.options.session_pageno_key);
@ -208,7 +253,7 @@ export default class App {
connAttemptGap: this.options.connAttemptGap,
}
this.worker.postMessage(messageData); // brings delay of 10th ms?
window.fetch(this.options.ingestPoint + '/v1/web/start', {
return window.fetch(this.options.ingestPoint + '/v1/web/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -230,14 +275,17 @@ export default class App {
if (r.status === 200) {
return r.json()
} else { // TODO: handle canceling && 403
throw new Error("Server error");
return r.text().then(text => {
throw new Error(`Server error: ${r.status}. ${text}`);
});
}
})
.then(r => {
const { token, userUUID, sessionID } = r;
const { token, userUUID, sessionID, beaconSizeLimit } = r;
if (typeof token !== 'string' ||
typeof userUUID !== 'string') {
throw new Error("Incorrect server response");
typeof userUUID !== 'string' ||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')) {
throw new Error(`Incorrect server response: ${ JSON.stringify(r) }`);
}
sessionStorage.setItem(this.options.session_token_key, token);
localStorage.setItem(this.options.local_uuid_key, userUUID);
@ -245,36 +293,43 @@ export default class App {
this._sessionID = sessionID;
}
if (!this.worker) {
throw new Error("Stranger things: no worker found after start request");
throw new Error("no worker found after start request (this might not happen)");
}
this.worker.postMessage({ token });
this.worker.postMessage({ token, beaconSizeLimit });
this.startCallbacks.forEach((cb) => cb());
this.observer.observe();
this.ticker.start();
log("OpenReplay tracking started.");
const onStartInfo = { sessionToken: token, userUUID, sessionID };
if (typeof this.options.onStart === 'function') {
this.options.onStart({ sessionToken: token, userUUID, sessionID });
this.options.onStart(onStartInfo);
}
return onStartInfo;
})
.catch(e => {
this.stop();
/* TODO: send report */
warn("OpenReplay was unable to start. ", e)
this.sendDebugReport("session_start", e);
throw e;
})
}
return Promise.reject("Player is active");
}
start(reset: boolean = false): void {
start(reset: boolean = false): Promise<OnStartInfo> {
if (!document.hidden) {
this._start(reset);
return this._start(reset);
} else {
const onVisibilityChange = () => {
if (!document.hidden) {
document.removeEventListener("visibilitychange", onVisibilityChange);
this._start(reset);
return new Promise((resolve) => {
const onVisibilityChange = () => {
if (!document.hidden) {
document.removeEventListener("visibilitychange", onVisibilityChange);
resolve(this._start(reset));
}
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
document.addEventListener("visibilitychange", onVisibilityChange);
});
}
}
stop(): void {

View file

@ -1,4 +1,4 @@
import { stars, hasOpenreplayAttribute, getBaseURI } from '../utils';
import { stars, hasOpenreplayAttribute } from '../utils';
import {
CreateDocument,
CreateElementNode,
@ -10,37 +10,49 @@ import {
RemoveNodeAttribute,
MoveNode,
RemoveNode,
CreateIFrameDocument,
} from '../../messages';
import App from './index';
interface Window extends WindowProxy {
HTMLInputElement: typeof HTMLInputElement,
HTMLLinkElement: typeof HTMLLinkElement,
HTMLStyleElement: typeof HTMLStyleElement,
SVGStyleElement: typeof SVGStyleElement,
HTMLIFrameElement: typeof HTMLIFrameElement,
Text: typeof Text,
Element: typeof Element,
//parent: Window,
}
type WindowConstructor =
Document |
Element |
Text |
HTMLInputElement |
HTMLLinkElement |
HTMLStyleElement |
HTMLIFrameElement
// type ConstructorNames =
// 'Element' |
// 'Text' |
// 'HTMLInputElement' |
// 'HTMLLinkElement' |
// 'HTMLStyleElement' |
// 'HTMLIFrameElement'
type Constructor<T> = { new (...args: any[]): T , name: string };
function isSVGElement(node: Element): node is SVGElement {
return node.namespaceURI === 'http://www.w3.org/2000/svg';
}
function isIgnored(node: Node): boolean {
if (node instanceof Text) {
return false;
}
if (!(node instanceof Element)) {
return true;
}
const tag = node.tagName.toUpperCase();
if (tag === 'LINK') {
const rel = node.getAttribute('rel');
const as = node.getAttribute('as');
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
}
return (
tag === 'SCRIPT' ||
tag === 'NOSCRIPT' ||
tag === 'META' ||
tag === 'TITLE' ||
tag === 'BASE'
);
}
export interface Options {
obscureTextEmails: boolean;
obscureTextNumbers: boolean;
captureIFrames: boolean;
}
export default class Observer {
@ -51,17 +63,33 @@ export default class Observer {
private readonly attributesList: Array<Set<string> | undefined>;
private readonly textSet: Set<number>;
private readonly textMasked: Set<number>;
private readonly options: Options;
constructor(private readonly app: App, opts: Options) {
this.options = opts;
constructor(private readonly app: App, private readonly options: Options, private readonly context: Window = window) {
this.observer = new MutationObserver(
this.app.safe((mutations) => {
for (const mutation of mutations) {
const target = mutation.target;
if (isIgnored(target) || !document.contains(target)) {
const type = mutation.type;
// Special case
// Document 'childList' might happen in case of iframe.
// TODO: generalize as much as possible
if (this.isInstance(target, Document)
&& type === 'childList'
//&& new Array(mutation.addedNodes).some(node => this.isInstance(node, HTMLHtmlElement))
) {
const parentFrame = target.defaultView?.frameElement
if (!parentFrame) { continue }
this.bindTree(target.documentElement)
const frameID = this.app.nodes.getID(parentFrame)
const docID = this.app.nodes.getID(target.documentElement)
if (frameID === undefined || docID === undefined) { continue }
this.app.send(CreateIFrameDocument(frameID, docID));
continue;
}
if (this.isIgnored(target) || !context.document.contains(target)) {
continue;
}
const type = mutation.type;
if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) {
this.bindTree(mutation.removedNodes[i]);
@ -114,6 +142,43 @@ export default class Observer {
this.textMasked.clear();
}
// TODO: we need a type expert here so we won't have to ignore the lines
private isInstance<T extends WindowConstructor>(node: Node, constr: Constructor<T>): node is T {
let context = this.context;
while(context.parent && context.parent !== context) {
// @ts-ignore
if (node instanceof context[constr.name]) {
return true
}
// @ts-ignore
context = context.parent
}
// @ts-ignore
return node instanceof context[constr.name]
}
private isIgnored(node: Node): boolean {
if (this.isInstance(node, Text)) {
return false;
}
if (!this.isInstance(node, Element)) {
return true;
}
const tag = node.tagName.toUpperCase();
if (tag === 'LINK') {
const rel = node.getAttribute('rel');
const as = node.getAttribute('as');
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
}
return (
tag === 'SCRIPT' ||
tag === 'NOSCRIPT' ||
tag === 'META' ||
tag === 'TITLE' ||
tag === 'BASE'
);
}
private sendNodeAttribute(
id: number,
node: Element,
@ -130,7 +195,7 @@ export default class Observer {
if (value.length > 1e5) {
value = '';
}
this.app.send(new SetNodeAttributeURLBased(id, name, value, getBaseURI()));
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
} else {
this.app.send(new SetNodeAttribute(id, name, value));
}
@ -148,7 +213,7 @@ export default class Observer {
}
if (
name === 'value' &&
node instanceof HTMLInputElement &&
this.isInstance(node, HTMLInputElement) &&
node.type !== 'button' &&
node.type !== 'reset' &&
node.type !== 'submit'
@ -159,8 +224,8 @@ export default class Observer {
this.app.send(new RemoveNodeAttribute(id, name));
return;
}
if (name === 'style' || name === 'href' && node instanceof HTMLLinkElement) {
this.app.send(new SetNodeAttributeURLBased(id, name, value, getBaseURI()));
if (name === 'style' || name === 'href' && this.isInstance(node, HTMLLinkElement)) {
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
return;
}
if (name === 'href' || value.length > 1e5) {
@ -170,8 +235,8 @@ export default class Observer {
}
private sendNodeData(id: number, parentElement: Element, data: string): void {
if (parentElement instanceof HTMLStyleElement || parentElement instanceof SVGStyleElement) {
this.app.send(new SetCSSDataURLBased(id, data, getBaseURI()));
if (this.isInstance(parentElement, HTMLStyleElement) || this.isInstance(parentElement, SVGStyleElement)) {
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
return;
}
if (this.textMasked.has(id)) {
@ -201,7 +266,7 @@ export default class Observer {
}
private bindTree(node: Node): void {
if (isIgnored(node)) {
if (this.isIgnored(node)) {
return;
}
this.bindNode(node);
@ -210,7 +275,7 @@ export default class Observer {
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
isIgnored(node) || this.app.nodes.getID(node) !== undefined
this.isIgnored(node) || this.app.nodes.getID(node) !== undefined
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
},
@ -231,7 +296,9 @@ export default class Observer {
private _commitNode(id: number, node: Node): boolean {
const parent = node.parentNode;
let parentID: number | undefined;
if (id !== 0) {
if (this.isInstance(node, HTMLHtmlElement)) {
this.indexes[id] = 0
} else {
if (parent === null) {
this.unbindNode(node);
return false;
@ -247,7 +314,7 @@ export default class Observer {
}
if (
this.textMasked.has(parentID) ||
(node instanceof Element && hasOpenreplayAttribute(node, 'masked'))
(this.isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))
) {
this.textMasked.add(id);
}
@ -271,7 +338,7 @@ export default class Observer {
throw 'commitNode: missing node index';
}
if (isNew === true) {
if (node instanceof Element) {
if (this.isInstance(node, Element)) {
if (parentID !== undefined) {
this.app.send(new
CreateElementNode(
@ -287,7 +354,12 @@ export default class Observer {
const attr = node.attributes[i];
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
}
} else if (node instanceof Text) {
if (this.isInstance(node, HTMLIFrameElement) &&
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))) {
this.handleIframe(node);
}
} else if (this.isInstance(node, Text)) {
// for text node id != 0, hence parentID !== undefined and parent is Element
this.app.send(new CreateTextNode(id, parentID as number, index));
this.sendNodeData(id, parent as Element, node.data);
@ -299,7 +371,7 @@ export default class Observer {
}
const attr = this.attributesList[id];
if (attr !== undefined) {
if (!(node instanceof Element)) {
if (!this.isInstance(node, Element)) {
throw 'commitNode: node is not an element';
}
for (const name of attr) {
@ -307,7 +379,7 @@ export default class Observer {
}
}
if (this.textSet.has(id)) {
if (!(node instanceof Text)) {
if (!this.isInstance(node, Text)) {
throw 'commitNode: node is not a text';
}
// for text node id != 0, hence parent is Element
@ -337,8 +409,44 @@ export default class Observer {
this.clear();
}
private iframeObservers: Observer[] = [];
private handleIframe(iframe: HTMLIFrameElement): void {
const handle = () => {
const context = iframe.contentWindow as Window | null
const id = this.app.nodes.getID(iframe)
if (!context || id === undefined) { return }
const observer = new Observer(this.app, this.options, context)
this.iframeObservers.push(observer)
observer.observeIframe(id, context)
}
this.app.attachEventListener(iframe, "load", handle)
handle()
}
// TODO: abstract common functionality, separate FrameObserver
private observeIframe(id: number, context: Window) {
const doc = context.document;
this.observer.observe(doc, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: false,
characterDataOldValue: false,
});
this.bindTree(doc.documentElement);
const docID = this.app.nodes.getID(doc.documentElement);
if (docID === undefined) {
console.log("Wrong")
return;
}
this.app.send(CreateIFrameDocument(id,docID));
this.commitNodes();
}
observe(): void {
this.observer.observe(document, {
this.observer.observe(this.context.document, {
childList: true,
attributes: true,
characterData: true,
@ -347,11 +455,13 @@ export default class Observer {
characterDataOldValue: false,
});
this.app.send(new CreateDocument());
this.bindTree(document.documentElement);
this.bindTree(this.context.document.documentElement);
this.commitNodes();
}
disconnect(): void {
this.iframeObservers.forEach(o => o.disconnect());
this.iframeObservers = [];
this.observer.disconnect();
this.clear();
}

View file

@ -20,12 +20,14 @@ import CSSRules from './modules/cssrules';
import { IN_BROWSER, deprecationWarn } from './utils';
import { Options as AppOptions } from './app';
import { Options as ExceptionOptions } from './modules/exception';
import { Options as ConsoleOptions } from './modules/console';
import { Options as ExceptionOptions } from './modules/exception';
import { Options as InputOptions } from './modules/input';
import { Options as MouseOptions } from './modules/mouse';
import { Options as PerformanceOptions } from './modules/performance';
import { Options as TimingOptions } from './modules/timing';
export type Options = Partial<
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & TimingOptions
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & MouseOptions & PerformanceOptions & TimingOptions
> & {
projectID?: number; // For the back compatibility only (deprecated)
projectKey: string;
@ -92,9 +94,9 @@ export default class API {
Exception(this.app, options);
Img(this.app);
Input(this.app, options);
Mouse(this.app);
Mouse(this.app, options);
Timing(this.app, options);
Performance(this.app);
Performance(this.app, options);
Scroll(this.app);
Longtasks(this.app);
(window as any).__OPENREPLAY__ = (window as any).__OPENREPLAY__ || this;
@ -223,18 +225,12 @@ export default class API {
}
}
handleError = (e: Error) => {
if (e instanceof Error && this.app !== null) {
handleError = (e: Error | ErrorEvent | PromiseRejectionEvent) => {
if (this.app === null) { return; }
if (e instanceof Error) {
this.app.send(getExceptionMessage(e, []));
}
}
handleErrorEvent = (e: ErrorEvent | PromiseRejectionEvent) => {
if (
(e instanceof ErrorEvent ||
('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
) &&
this.app !== null
} else if (e instanceof ErrorEvent ||
('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
) {
const msg = getExceptionMessageFromEvent(e);
if (msg != null) {

View file

@ -1,6 +1,5 @@
import App from '../app';
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../../messages';
import { getBaseURI } from '../utils';
export default function(app: App | null) {
if (app === null) {
@ -14,7 +13,7 @@ export default function(app: App | null) {
const processOperation = app.safe(
(stylesheet: CSSStyleSheet, index: number, rule?: string) => {
const sendMessage = typeof rule === 'string'
? (nodeID: number) => app.send(new CSSInsertRuleURLBased(nodeID, rule, index, getBaseURI()))
? (nodeID: number) => app.send(new CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref()))
: (nodeID: number) => app.send(new CSSDeleteRule(nodeID, index));
// TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule)
if (stylesheet.ownerNode == null) {

View file

@ -1,4 +1,4 @@
import { timestamp, isURL, getBaseURI } from '../utils';
import { timestamp, isURL } from '../utils';
import App from '../app';
import { ResourceTiming, SetNodeAttributeURLBased } from '../../messages';
@ -17,7 +17,7 @@ export default function (app: App): void {
app.send(new ResourceTiming(timestamp(), 0, 0, 0, 0, 0, src, 'img'));
}
} else if (src.length < 1e5) {
app.send(new SetNodeAttributeURLBased(id, 'src', src, getBaseURI()));
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
}
});
@ -30,7 +30,7 @@ export default function (app: App): void {
return;
}
const src = target.src;
app.send(new SetNodeAttributeURLBased(id, 'src', src, getBaseURI()));
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
}
}
});

View file

@ -1,14 +1,10 @@
import type { Options as FinderOptions } from '../vendors/finder/finder';
import { finder } from '../vendors/finder/finder';
import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from '../utils';
import App from '../app';
import { MouseMove, MouseClick } from '../../messages';
import { getInputLabel } from './input';
const selectorMap: {[id:number]: string} = {};
function getSelector(id: number, target: Element): string {
return selectorMap[id] = selectorMap[id] || finder(target);
}
function getTarget(target: EventTarget | null): Element | null {
if (target instanceof Element) {
return _getTarget(target);
@ -76,7 +72,27 @@ function getTargetLabel(target: Element): string {
return '';
}
export default function (app: App): void {
interface HeatmapsOptions {
finder: FinderOptions,
}
export interface Options {
heatmaps: boolean | HeatmapsOptions;
}
export default function (app: App, opts: Partial<Options>): void {
const options: Options = Object.assign(
{
heatmaps: {
finder: {
threshold: 5,
maxNumberOfTries: 600,
},
},
},
opts,
);
let mousePositionX = -1;
let mousePositionY = -1;
let mousePositionChanged = false;
@ -97,6 +113,13 @@ export default function (app: App): void {
}
};
const selectorMap: {[id:number]: string} = {};
function getSelector(id: number, target: Element): string {
if (options.heatmaps === false) { return '' }
return selectorMap[id] = selectorMap[id] ||
finder(target, options.heatmaps === true ? undefined : options.heatmaps.finder);
}
app.attachEventListener(
<HTMLElement>document.documentElement,
'mouseover',

View file

@ -11,7 +11,7 @@ type Perf = {
}
}
const perf: Perf = IN_BROWSER && 'memory' in performance // works in Chrome only
const perf: Perf = IN_BROWSER && 'performance' in window && 'memory' in performance // works in Chrome only
? performance as any
: { memory: {} }
@ -19,7 +19,19 @@ const perf: Perf = IN_BROWSER && 'memory' in performance // works in Chrome only
export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0;
export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0;
export default function (app: App): void {
export interface Options {
capturePerformance: boolean;
}
export default function (app: App, opts: Partial<Options>): void {
const options: Options = Object.assign(
{
capturePerformance: true,
},
opts,
);
if (!options.capturePerformance) { return; }
let frames: number | undefined;
let ticks: number | undefined;

View file

@ -1,6 +1,7 @@
import { isURL } from '../utils';
import App from '../app';
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../../messages';
import type Message from '../../messages/message';
// Inspired by https://github.com/WPO-Foundation/RUM-SpeedIndex/blob/master/src/rum-speedindex.js
@ -104,21 +105,28 @@ export default function (app: App, opts: Partial<Options>): void {
if (!('PerformanceObserver' in window)) {
options.captureResourceTimings = false;
}
if (!options.captureResourceTimings) {
options.capturePageLoadTimings = false;
options.capturePageRenderTimings = false;
}
if (!options.captureResourceTimings) { return } // Resources are necessary for all timings
let resources: ResourcesTimeMap | null = options.captureResourceTimings
? {}
: null;
const mQueue: Message[] = []
function sendOnStart(m: Message) {
if (app.active()) {
app.send(m)
} else {
mQueue.push(m)
}
}
app.attachStartCallback(function() {
mQueue.forEach(m => app.send(m))
})
let resources: ResourcesTimeMap | null = {}
function resourceTiming(entry: PerformanceResourceTiming): void {
if (entry.duration <= 0 || !isURL(entry.name) || app.isServiceURL(entry.name)) return;
if (resources !== null) {
resources[entry.name] = entry.startTime + entry.duration;
}
app.send(new
sendOnStart(new
ResourceTiming(
entry.startTime + performance.timing.navigationStart,
entry.duration,
@ -136,20 +144,17 @@ export default function (app: App, opts: Partial<Options>): void {
);
}
const observer: PerformanceObserver | null = options.captureResourceTimings
? new PerformanceObserver((list) =>
list.getEntries().forEach(resourceTiming),
)
: null;
if (observer !== null) {
performance.getEntriesByType('resource').forEach(resourceTiming);
observer.observe({ entryTypes: ['resource'] });
}
const observer: PerformanceObserver = new PerformanceObserver(
(list) => list.getEntries().forEach(resourceTiming),
)
performance.getEntriesByType('resource').forEach(resourceTiming)
observer.observe({ entryTypes: ['resource'] })
let firstPaint = 0,
firstContentfulPaint = 0;
if (options.capturePageLoadTimings && observer !== null) {
if (options.capturePageLoadTimings) {
let pageLoadTimingSent: boolean = false;
app.ticker.attach(() => {
@ -200,7 +205,7 @@ export default function (app: App, opts: Partial<Options>): void {
}, 30);
}
if (options.capturePageRenderTimings && observer !== null) {
if (options.capturePageRenderTimings) {
let visuallyComplete = 0,
interactiveWindowStartTime = 0,
interactiveWindowTickTime: number | null = 0,

View file

@ -16,16 +16,6 @@ export function isURL(s: string): boolean {
return s.substr(0, 8) === 'https://' || s.substr(0, 7) === 'http://';
}
export function getBaseURI(): string {
if (document.baseURI) {
return document.baseURI;
}
// IE only
return document.head
?.getElementsByTagName("base")[0]
?.getAttribute("href") || location.origin + location.pathname;
}
export const IN_BROWSER = !(typeof window === "undefined");
export const log = console.log

View file

@ -1,12 +0,0 @@
export declare type Options = {
root: Element;
idName: (name: string) => boolean;
className: (name: string) => boolean;
tagName: (name: string) => boolean;
attr: (name: string, value: string) => boolean;
seedMinLength: number;
optimizedMinLength: number;
threshold: number;
maxNumberOfTries: number;
};
export declare function finder(input: Element, options?: Partial<Options>): string;

View file

@ -1,339 +0,0 @@
var Limit;
(function (Limit) {
Limit[Limit["All"] = 0] = "All";
Limit[Limit["Two"] = 1] = "Two";
Limit[Limit["One"] = 2] = "One";
})(Limit || (Limit = {}));
let config;
let rootDocument;
export function finder(input, options) {
if (input.nodeType !== Node.ELEMENT_NODE) {
throw new Error(`Can't generate CSS selector for non-element node type.`);
}
if ("html" === input.tagName.toLowerCase()) {
return "html";
}
const defaults = {
root: document.body,
idName: (name) => true,
className: (name) => true,
tagName: (name) => true,
attr: (name, value) => false,
seedMinLength: 1,
optimizedMinLength: 2,
threshold: 1000,
maxNumberOfTries: 10000,
};
config = Object.assign(Object.assign({}, defaults), options);
rootDocument = findRootDocument(config.root, defaults);
let path = bottomUpSearch(input, Limit.All, () => bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One)));
if (path) {
const optimized = sort(optimize(path, input));
if (optimized.length > 0) {
path = optimized[0];
}
return selector(path);
}
else {
throw new Error(`Selector was not found.`);
}
}
function findRootDocument(rootNode, defaults) {
if (rootNode.nodeType === Node.DOCUMENT_NODE) {
return rootNode;
}
if (rootNode === defaults.root) {
return rootNode.ownerDocument;
}
return rootNode;
}
function bottomUpSearch(input, limit, fallback) {
let path = null;
let stack = [];
let current = input;
let i = 0;
while (current && current !== config.root.parentElement) {
let level = maybe(id(current)) || maybe(...attr(current)) || maybe(...classNames(current)) || maybe(tagName(current)) || [any()];
const nth = index(current);
if (limit === Limit.All) {
if (nth) {
level = level.concat(level.filter(dispensableNth).map(node => nthChild(node, nth)));
}
}
else if (limit === Limit.Two) {
level = level.slice(0, 1);
if (nth) {
level = level.concat(level.filter(dispensableNth).map(node => nthChild(node, nth)));
}
}
else if (limit === Limit.One) {
const [node] = level = level.slice(0, 1);
if (nth && dispensableNth(node)) {
level = [nthChild(node, nth)];
}
}
for (let node of level) {
node.level = i;
}
stack.push(level);
if (stack.length >= config.seedMinLength) {
path = findUniquePath(stack, fallback);
if (path) {
break;
}
}
current = current.parentElement;
i++;
}
if (!path) {
path = findUniquePath(stack, fallback);
}
return path;
}
function findUniquePath(stack, fallback) {
const paths = sort(combinations(stack));
if (paths.length > config.threshold) {
return fallback ? fallback() : null;
}
for (let candidate of paths) {
if (unique(candidate)) {
return candidate;
}
}
return null;
}
function selector(path) {
let node = path[0];
let query = node.name;
for (let i = 1; i < path.length; i++) {
const level = path[i].level || 0;
if (node.level === level - 1) {
query = `${path[i].name} > ${query}`;
}
else {
query = `${path[i].name} ${query}`;
}
node = path[i];
}
return query;
}
function penalty(path) {
return path.map(node => node.penalty).reduce((acc, i) => acc + i, 0);
}
function unique(path) {
switch (rootDocument.querySelectorAll(selector(path)).length) {
case 0:
throw new Error(`Can't select any node with this selector: ${selector(path)}`);
case 1:
return true;
default:
return false;
}
}
function id(input) {
const elementId = input.getAttribute("id");
if (elementId && config.idName(elementId)) {
return {
name: "#" + cssesc(elementId, { isIdentifier: true }),
penalty: 0,
};
}
return null;
}
function attr(input) {
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value));
return attrs.map((attr) => ({
name: "[" + cssesc(attr.name, { isIdentifier: true }) + "=\"" + cssesc(attr.value) + "\"]",
penalty: 0.5
}));
}
function classNames(input) {
const names = Array.from(input.classList)
.filter(config.className);
return names.map((name) => ({
name: "." + cssesc(name, { isIdentifier: true }),
penalty: 1
}));
}
function tagName(input) {
const name = input.tagName.toLowerCase();
if (config.tagName(name)) {
return {
name,
penalty: 2
};
}
return null;
}
function any() {
return {
name: "*",
penalty: 3
};
}
function index(input) {
const parent = input.parentNode;
if (!parent) {
return null;
}
let child = parent.firstChild;
if (!child) {
return null;
}
let i = 0;
while (child) {
if (child.nodeType === Node.ELEMENT_NODE) {
i++;
}
if (child === input) {
break;
}
child = child.nextSibling;
}
return i;
}
function nthChild(node, i) {
return {
name: node.name + `:nth-child(${i})`,
penalty: node.penalty + 1
};
}
function dispensableNth(node) {
return node.name !== "html" && !node.name.startsWith("#");
}
function maybe(...level) {
const list = level.filter(notEmpty);
if (list.length > 0) {
return list;
}
return null;
}
function notEmpty(value) {
return value !== null && value !== undefined;
}
function* combinations(stack, path = []) {
if (stack.length > 0) {
for (let node of stack[0]) {
yield* combinations(stack.slice(1, stack.length), path.concat(node));
}
}
else {
yield path;
}
}
function sort(paths) {
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b));
}
function* optimize(path, input, scope = {
counter: 0,
visited: new Map()
}) {
if (path.length > 2 && path.length > config.optimizedMinLength) {
for (let i = 1; i < path.length - 1; i++) {
if (scope.counter > config.maxNumberOfTries) {
return; // Okay At least I tried!
}
scope.counter += 1;
const newPath = [...path];
newPath.splice(i, 1);
const newPathKey = selector(newPath);
if (scope.visited.has(newPathKey)) {
return;
}
if (unique(newPath) && same(newPath, input)) {
yield newPath;
scope.visited.set(newPathKey, true);
yield* optimize(newPath, input, scope);
}
}
}
}
function same(path, input) {
return rootDocument.querySelector(selector(path)) === input;
}
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/;
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/;
const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g;
const defaultOptions = {
"escapeEverything": false,
"isIdentifier": false,
"quotes": "single",
"wrap": false
};
function cssesc(string, opt = {}) {
const options = Object.assign(Object.assign({}, defaultOptions), opt);
if (options.quotes != "single" && options.quotes != "double") {
options.quotes = "single";
}
const quote = options.quotes == "double" ? "\"" : "'";
const isIdentifier = options.isIdentifier;
const firstChar = string.charAt(0);
let output = "";
let counter = 0;
const length = string.length;
while (counter < length) {
const character = string.charAt(counter++);
let codePoint = character.charCodeAt(0);
let value = void 0;
// If its not a printable ASCII character…
if (codePoint < 0x20 || codePoint > 0x7E) {
if (codePoint >= 0xD800 && codePoint <= 0xDBFF && counter < length) {
// Its a high surrogate, and there is a next character.
const extra = string.charCodeAt(counter++);
if ((extra & 0xFC00) == 0xDC00) {
// next character is low surrogate
codePoint = ((codePoint & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000;
}
else {
// Its an unmatched surrogate; only append this code unit, in case
// the next code unit is the high surrogate of a surrogate pair.
counter--;
}
}
value = "\\" + codePoint.toString(16).toUpperCase() + " ";
}
else {
if (options.escapeEverything) {
if (regexAnySingleEscape.test(character)) {
value = "\\" + character;
}
else {
value = "\\" + codePoint.toString(16).toUpperCase() + " ";
}
}
else if (/[\t\n\f\r\x0B]/.test(character)) {
value = "\\" + codePoint.toString(16).toUpperCase() + " ";
}
else if (character == "\\" || !isIdentifier && (character == "\"" && quote == character || character == "'" && quote == character) || isIdentifier && regexSingleEscape.test(character)) {
value = "\\" + character;
}
else {
value = character;
}
}
output += value;
}
if (isIdentifier) {
if (/^-[-\d]/.test(output)) {
output = "\\-" + output.slice(1);
}
else if (/\d/.test(firstChar)) {
output = "\\3" + firstChar + " " + output.slice(1);
}
}
// Remove spaces after `\HEX` escapes that are not followed by a hex digit,
// since theyre redundant. Note that this is only possible if the escape
// sequence isnt preceded by an odd number of backslashes.
output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) {
if ($1 && $1.length % 2) {
// Its not safe to remove the space, so dont.
return $0;
}
// Strip the space.
return ($1 || "") + $2;
});
if (!isIdentifier && options.wrap) {
return quote + output + quote;
}
return output;
}

View file

@ -279,14 +279,16 @@ function notEmpty<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
function* combinations(stack: Node[][], path: Node[] = []): Generator<Node[]> {
function combinations(stack: Node[][], path: Node[] = []): Node[][] {
const paths: Node[][] = []
if (stack.length > 0) {
for (let node of stack[0]) {
yield* combinations(stack.slice(1, stack.length), path.concat(node))
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)))
}
} else {
yield path
paths.push(path)
}
return paths
}
function sort(paths: Iterable<Path>): Path[] {
@ -298,29 +300,31 @@ type Scope = {
visited: Map<string, boolean>
}
function* optimize(path: Path, input: Element, scope: Scope = {
function optimize(path: Path, input: Element, scope: Scope = {
counter: 0,
visited: new Map<string, boolean>()
}): Generator<Node[]> {
}): Node[][] {
const paths: Node[][] = []
if (path.length > 2 && path.length > config.optimizedMinLength) {
for (let i = 1; i < path.length - 1; i++) {
if (scope.counter > config.maxNumberOfTries) {
return // Okay At least I tried!
return paths // Okay At least I tried!
}
scope.counter += 1
const newPath = [...path]
newPath.splice(i, 1)
const newPathKey = selector(newPath)
if (scope.visited.has(newPathKey)) {
return
return paths
}
if (unique(newPath) && same(newPath, input)) {
yield newPath
paths.push(newPath)
scope.visited.set(newPathKey, true)
yield* optimize(newPath, input, scope)
paths.push(...optimize(newPath, input, scope))
}
}
}
return paths
}
function same(path: Path, input: Element) {

View file

@ -885,3 +885,19 @@ export const MouseClick = bindNew(_MouseClick);
classes.set(69, MouseClick);
class _CreateIFrameDocument implements Message {
readonly _id: number = 70;
constructor(
public frameID: number,
public id: number
) {}
encode(writer: Writer): boolean {
return writer.uint(70) &&
writer.uint(this.frameID) &&
writer.uint(this.id);
}
}
export const CreateIFrameDocument = bindNew(_CreateIFrameDocument);
classes.set(70, CreateIFrameDocument);

View file

@ -11,6 +11,7 @@ type Settings = {
pageNo?: number;
startTimestamp?: number;
timeAdjustment?: number;
beaconSizeLimit?: number;
} & Partial<Options>;
export type WorkerMessageData = null | "stop" | Settings | Array<{ _id: number }>;

View file

@ -6,7 +6,7 @@ import type { WorkerMessageData } from '../messages/webworker';
const SEND_INTERVAL = 20 * 1000;
const BEACON_SIZE_LIMIT = 1e6 // Limit is set in the backend/services/http
let BEACON_SIZE_LIMIT = 1e6 // Limit is set in the backend/services/http
let beaconSize = 4 * 1e5; // Default 400kB
@ -123,6 +123,7 @@ self.onmessage = ({ data }: MessageEvent<WorkerMessageData>) => {
timeAdjustment = data.timeAdjustment || timeAdjustment;
MAX_ATTEMPTS_COUNT = data.connAttemptCount || MAX_ATTEMPTS_COUNT;
ATTEMPT_TIMEOUT = data.connAttemptGap || ATTEMPT_TIMEOUT;
BEACON_SIZE_LIMIT = data.beaconSizeLimit || BEACON_SIZE_LIMIT;
beaconSize = Math.min(BEACON_SIZE_LIMIT, data.beaconSize || beaconSize);
if (writer.isEmpty()) {
writeBatchMeta();

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@
},
"homepage": "https://github.com/openreplay/openreplay#readme",
"dependencies": {
"aws-sdk": "^2.654.0",
"aws-sdk": "^2.992.0",
"express": "^4.17.1",
"peer": "^0.6.1",
"source-map": "^0.7.3"

View file

@ -24,7 +24,7 @@ const peerServer = ExpressPeerServer(server, {
debug: true,
path: '/',
proxied: true,
allow_discovery: true
allow_discovery: false
});
peerServer.on('connection', peerConnection);
peerServer.on('disconnect', peerDisconnect);

View file

@ -48,13 +48,13 @@ const peerError = (error) => {
}
peerRouter.get('/peers', function (req, res) {
peerRouter.get(`/${process.env.S3_KEY}/peers`, function (req, res) {
console.log("looking for all available sessions");
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({"data": connectedPeers}));
});
peerRouter.get('/peers/:projectKey', function (req, res) {
peerRouter.get(`/${process.env.S3_KEY}/peers/:projectKey`, function (req, res) {
console.log(`looking for available sessions for ${req.params.projectKey}`);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');