@leanbase-giangnd/js 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/dist/index.cjs +6012 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +1484 -0
- package/dist/index.mjs +6010 -0
- package/dist/index.mjs.map +1 -0
- package/dist/leanbase.iife.js +13431 -0
- package/dist/leanbase.iife.js.map +1 -0
- package/package.json +48 -0
- package/src/autocapture-utils.ts +550 -0
- package/src/autocapture.ts +415 -0
- package/src/config.ts +8 -0
- package/src/constants.ts +108 -0
- package/src/extensions/rageclick.ts +34 -0
- package/src/extensions/replay/external/config.ts +278 -0
- package/src/extensions/replay/external/denylist.ts +32 -0
- package/src/extensions/replay/external/lazy-loaded-session-recorder.ts +1376 -0
- package/src/extensions/replay/external/mutation-throttler.ts +109 -0
- package/src/extensions/replay/external/network-plugin.ts +701 -0
- package/src/extensions/replay/external/sessionrecording-utils.ts +141 -0
- package/src/extensions/replay/external/triggerMatching.ts +422 -0
- package/src/extensions/replay/rrweb-plugins/patch.ts +39 -0
- package/src/extensions/replay/session-recording.ts +285 -0
- package/src/extensions/replay/types/rrweb-types.ts +575 -0
- package/src/extensions/replay/types/rrweb.ts +114 -0
- package/src/extensions/sampling.ts +26 -0
- package/src/iife.ts +87 -0
- package/src/index.ts +2 -0
- package/src/leanbase-logger.ts +26 -0
- package/src/leanbase-persistence.ts +374 -0
- package/src/leanbase.ts +457 -0
- package/src/page-view.ts +124 -0
- package/src/scroll-manager.ts +103 -0
- package/src/session-props.ts +114 -0
- package/src/sessionid.ts +330 -0
- package/src/storage.ts +410 -0
- package/src/types/fflate.d.ts +5 -0
- package/src/types/rrweb-record.d.ts +8 -0
- package/src/types.ts +807 -0
- package/src/utils/blocked-uas.ts +162 -0
- package/src/utils/element-utils.ts +50 -0
- package/src/utils/event-utils.ts +304 -0
- package/src/utils/index.ts +222 -0
- package/src/utils/logger.ts +26 -0
- package/src/utils/request-utils.ts +128 -0
- package/src/utils/simple-event-emitter.ts +27 -0
- package/src/utils/user-agent-utils.ts +357 -0
- package/src/uuidv7.ts +268 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { CapturedNetworkRequest, LeanbaseConfig, NetworkRecordOptions } from '../../../types'
|
|
2
|
+
import { isArray, isBoolean, isFunction, isNullish, isString, isUndefined } from '@posthog/core'
|
|
3
|
+
import { convertToURL } from '../../../utils/request-utils'
|
|
4
|
+
import { logger } from '../../../utils/logger'
|
|
5
|
+
import { shouldCaptureValue } from '../../../autocapture-utils'
|
|
6
|
+
import { each } from '../../../utils'
|
|
7
|
+
|
|
8
|
+
const LOGGER_PREFIX = '[SessionRecording]'
|
|
9
|
+
|
|
10
|
+
const REDACTED = 'redacted'
|
|
11
|
+
|
|
12
|
+
export const defaultNetworkOptions: Required<NetworkRecordOptions> = {
|
|
13
|
+
initiatorTypes: [
|
|
14
|
+
'audio',
|
|
15
|
+
'beacon',
|
|
16
|
+
'body',
|
|
17
|
+
'css',
|
|
18
|
+
'early-hints',
|
|
19
|
+
'embed',
|
|
20
|
+
'fetch',
|
|
21
|
+
'frame',
|
|
22
|
+
'iframe',
|
|
23
|
+
'image',
|
|
24
|
+
'img',
|
|
25
|
+
'input',
|
|
26
|
+
'link',
|
|
27
|
+
'navigation',
|
|
28
|
+
'object',
|
|
29
|
+
'ping',
|
|
30
|
+
'script',
|
|
31
|
+
'track',
|
|
32
|
+
'video',
|
|
33
|
+
'xmlhttprequest',
|
|
34
|
+
],
|
|
35
|
+
maskRequestFn: (data: CapturedNetworkRequest) => data,
|
|
36
|
+
recordHeaders: false,
|
|
37
|
+
recordBody: false,
|
|
38
|
+
recordInitialRequests: false,
|
|
39
|
+
recordPerformance: false,
|
|
40
|
+
performanceEntryTypeToObserve: [
|
|
41
|
+
// 'event', // This is too noisy as it covers all browser events
|
|
42
|
+
'first-input',
|
|
43
|
+
// 'mark', // Mark is used too liberally. We would need to filter for specific marks
|
|
44
|
+
// 'measure', // Measure is used too liberally. We would need to filter for specific measures
|
|
45
|
+
'navigation',
|
|
46
|
+
'paint',
|
|
47
|
+
'resource',
|
|
48
|
+
],
|
|
49
|
+
payloadSizeLimitBytes: 1000000,
|
|
50
|
+
payloadHostDenyList: [
|
|
51
|
+
'.lr-ingest.io',
|
|
52
|
+
'.ingest.sentry.io',
|
|
53
|
+
'.clarity.ms',
|
|
54
|
+
// NB no leading dot here
|
|
55
|
+
'analytics.google.com',
|
|
56
|
+
'bam.nr-data.net',
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const HEADER_DENY_LIST = [
|
|
61
|
+
'authorization',
|
|
62
|
+
'x-forwarded-for',
|
|
63
|
+
'authorization',
|
|
64
|
+
'cookie',
|
|
65
|
+
'set-cookie',
|
|
66
|
+
'x-api-key',
|
|
67
|
+
'x-real-ip',
|
|
68
|
+
'remote-addr',
|
|
69
|
+
'forwarded',
|
|
70
|
+
'proxy-authorization',
|
|
71
|
+
'x-csrf-token',
|
|
72
|
+
'x-csrftoken',
|
|
73
|
+
'x-xsrf-token',
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const PAYLOAD_CONTENT_DENY_LIST = [
|
|
77
|
+
'password',
|
|
78
|
+
'secret',
|
|
79
|
+
'passwd',
|
|
80
|
+
'api_key',
|
|
81
|
+
'apikey',
|
|
82
|
+
'auth',
|
|
83
|
+
'credentials',
|
|
84
|
+
'mysql_pwd',
|
|
85
|
+
'privatekey',
|
|
86
|
+
'private_key',
|
|
87
|
+
'token',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
// we always remove headers on the deny list because we never want to capture this sensitive data
|
|
91
|
+
const removeAuthorizationHeader = (data: CapturedNetworkRequest): CapturedNetworkRequest => {
|
|
92
|
+
const headers = data.requestHeaders
|
|
93
|
+
if (!isNullish(headers)) {
|
|
94
|
+
const mutableHeaders: Record<string, any> = isArray(headers)
|
|
95
|
+
? Object.fromEntries(headers as any)
|
|
96
|
+
: (headers as any)
|
|
97
|
+
|
|
98
|
+
each(Object.keys(mutableHeaders ?? {}), (header) => {
|
|
99
|
+
if (HEADER_DENY_LIST.includes(header.toLowerCase())) {
|
|
100
|
+
mutableHeaders[header] = REDACTED
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
data.requestHeaders = mutableHeaders as any
|
|
105
|
+
}
|
|
106
|
+
return data
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const POSTHOG_PATHS_TO_IGNORE = ['/s/', '/e/', '/i/']
|
|
110
|
+
// want to ignore posthog paths when capturing requests, or we can get trapped in a loop
|
|
111
|
+
// because calls to PostHog would be reported using a call to PostHog which would be reported....
|
|
112
|
+
const ignorePostHogPaths = (
|
|
113
|
+
data: CapturedNetworkRequest,
|
|
114
|
+
apiHostConfig: LeanbaseConfig['host']
|
|
115
|
+
): CapturedNetworkRequest | undefined => {
|
|
116
|
+
const url = convertToURL(data.name)
|
|
117
|
+
|
|
118
|
+
const host = apiHostConfig || ''
|
|
119
|
+
let replaceValue = host.indexOf('http') === 0 ? convertToURL(host)?.pathname : host
|
|
120
|
+
if (replaceValue === '/') {
|
|
121
|
+
replaceValue = ''
|
|
122
|
+
}
|
|
123
|
+
const pathname = url?.pathname.replace(replaceValue || '', '')
|
|
124
|
+
|
|
125
|
+
if (url && pathname && POSTHOG_PATHS_TO_IGNORE.some((path) => pathname.indexOf(path) === 0)) {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
return data
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function estimateBytes(payload: string): number {
|
|
132
|
+
return new Blob([payload]).size
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function enforcePayloadSizeLimit(
|
|
136
|
+
payload: string | null | undefined,
|
|
137
|
+
headers: Record<string, any> | undefined,
|
|
138
|
+
limit: number,
|
|
139
|
+
description: string
|
|
140
|
+
): string | null | undefined {
|
|
141
|
+
if (isNullish(payload)) {
|
|
142
|
+
return payload
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let requestContentLength: string | number = headers?.['content-length'] || estimateBytes(payload)
|
|
146
|
+
if (isString(requestContentLength)) {
|
|
147
|
+
requestContentLength = parseInt(requestContentLength)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (requestContentLength > limit) {
|
|
151
|
+
return LOGGER_PREFIX + ` ${description} body too large to record (${requestContentLength} bytes)`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return payload
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// people can have arbitrarily large payloads on their site, but we don't want to ingest them
|
|
158
|
+
const limitPayloadSize = (
|
|
159
|
+
options: NetworkRecordOptions
|
|
160
|
+
): ((data: CapturedNetworkRequest | undefined) => CapturedNetworkRequest | undefined) => {
|
|
161
|
+
// the smallest of 1MB or the specified limit if there is one
|
|
162
|
+
const limit = Math.min(1000000, options.payloadSizeLimitBytes ?? 1000000)
|
|
163
|
+
|
|
164
|
+
return (data) => {
|
|
165
|
+
if (data?.requestBody) {
|
|
166
|
+
data.requestBody = enforcePayloadSizeLimit(data.requestBody, data.requestHeaders, limit, 'Request')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (data?.responseBody) {
|
|
170
|
+
data.responseBody = enforcePayloadSizeLimit(data.responseBody, data.responseHeaders, limit, 'Response')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return data
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function scrubPayload(payload: string | null | undefined, label: 'Request' | 'Response'): string | null | undefined {
|
|
178
|
+
if (isNullish(payload)) {
|
|
179
|
+
return payload
|
|
180
|
+
}
|
|
181
|
+
let scrubbed = payload
|
|
182
|
+
|
|
183
|
+
if (!shouldCaptureValue(scrubbed, false)) {
|
|
184
|
+
scrubbed = LOGGER_PREFIX + ' ' + label + ' body ' + REDACTED
|
|
185
|
+
}
|
|
186
|
+
each(PAYLOAD_CONTENT_DENY_LIST, (text) => {
|
|
187
|
+
if (scrubbed?.length && scrubbed?.indexOf(text) !== -1) {
|
|
188
|
+
scrubbed = LOGGER_PREFIX + ' ' + label + ' body ' + REDACTED + ' as might contain: ' + text
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return scrubbed
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function scrubPayloads(capturedRequest: CapturedNetworkRequest | undefined): CapturedNetworkRequest | undefined {
|
|
196
|
+
if (isUndefined(capturedRequest)) {
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
capturedRequest.requestBody = scrubPayload(capturedRequest.requestBody, 'Request')
|
|
201
|
+
capturedRequest.responseBody = scrubPayload(capturedRequest.responseBody, 'Response')
|
|
202
|
+
|
|
203
|
+
return capturedRequest
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* whether a maskRequestFn is provided or not,
|
|
208
|
+
* we ensure that we remove the denied header from requests
|
|
209
|
+
* we _never_ want to record that header by accident
|
|
210
|
+
* if someone complains then we'll add an opt-in to let them override it
|
|
211
|
+
*/
|
|
212
|
+
export const buildNetworkRequestOptions = (
|
|
213
|
+
instanceConfig: LeanbaseConfig,
|
|
214
|
+
remoteNetworkOptions: Pick<
|
|
215
|
+
NetworkRecordOptions,
|
|
216
|
+
'recordHeaders' | 'recordBody' | 'recordPerformance' | 'payloadHostDenyList'
|
|
217
|
+
> = {}
|
|
218
|
+
): NetworkRecordOptions => {
|
|
219
|
+
const remoteOptions = remoteNetworkOptions || {}
|
|
220
|
+
const config: NetworkRecordOptions = {
|
|
221
|
+
payloadSizeLimitBytes: defaultNetworkOptions.payloadSizeLimitBytes,
|
|
222
|
+
performanceEntryTypeToObserve: [...defaultNetworkOptions.performanceEntryTypeToObserve],
|
|
223
|
+
payloadHostDenyList: [
|
|
224
|
+
...(remoteOptions.payloadHostDenyList || []),
|
|
225
|
+
...defaultNetworkOptions.payloadHostDenyList,
|
|
226
|
+
],
|
|
227
|
+
}
|
|
228
|
+
// client can always disable despite remote options
|
|
229
|
+
const sessionRecordingConfig = instanceConfig.session_recording || {}
|
|
230
|
+
const capturePerformanceConfig = instanceConfig.capture_performance
|
|
231
|
+
const userPerformanceOptIn = isBoolean(capturePerformanceConfig)
|
|
232
|
+
? capturePerformanceConfig
|
|
233
|
+
: !!capturePerformanceConfig?.network_timing
|
|
234
|
+
const canRecordHeaders = sessionRecordingConfig.recordHeaders === true && !!remoteOptions.recordHeaders
|
|
235
|
+
const canRecordBody = sessionRecordingConfig.recordBody === true && !!remoteOptions.recordBody
|
|
236
|
+
const canRecordPerformance = userPerformanceOptIn && !!remoteOptions.recordPerformance
|
|
237
|
+
|
|
238
|
+
const payloadLimiter = limitPayloadSize(config)
|
|
239
|
+
|
|
240
|
+
const enforcedCleaningFn: NetworkRecordOptions['maskRequestFn'] = (d: CapturedNetworkRequest) =>
|
|
241
|
+
payloadLimiter(ignorePostHogPaths(removeAuthorizationHeader(d), instanceConfig.host || ''))
|
|
242
|
+
|
|
243
|
+
const hasDeprecatedMaskFunction = isFunction(sessionRecordingConfig.maskNetworkRequestFn)
|
|
244
|
+
|
|
245
|
+
if (hasDeprecatedMaskFunction && isFunction(sessionRecordingConfig.maskCapturedNetworkRequestFn)) {
|
|
246
|
+
logger.warn(
|
|
247
|
+
'Both `maskNetworkRequestFn` and `maskCapturedNetworkRequestFn` are defined. `maskNetworkRequestFn` will be ignored.'
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (hasDeprecatedMaskFunction) {
|
|
252
|
+
sessionRecordingConfig.maskCapturedNetworkRequestFn = (data: CapturedNetworkRequest) => {
|
|
253
|
+
const cleanedURL = sessionRecordingConfig.maskNetworkRequestFn!({ url: data.name })
|
|
254
|
+
return {
|
|
255
|
+
...data,
|
|
256
|
+
name: cleanedURL?.url,
|
|
257
|
+
} as CapturedNetworkRequest
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
config.maskRequestFn = isFunction(sessionRecordingConfig.maskCapturedNetworkRequestFn)
|
|
262
|
+
? (data) => {
|
|
263
|
+
const cleanedRequest = enforcedCleaningFn(data)
|
|
264
|
+
return cleanedRequest
|
|
265
|
+
? (sessionRecordingConfig.maskCapturedNetworkRequestFn?.(cleanedRequest) ?? undefined)
|
|
266
|
+
: undefined
|
|
267
|
+
}
|
|
268
|
+
: (data) => scrubPayloads(enforcedCleaningFn(data))
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
...defaultNetworkOptions,
|
|
272
|
+
...config,
|
|
273
|
+
recordHeaders: canRecordHeaders,
|
|
274
|
+
recordBody: canRecordBody,
|
|
275
|
+
recordPerformance: canRecordPerformance,
|
|
276
|
+
recordInitialRequests: canRecordPerformance,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NetworkRecordOptions } from '../../../types'
|
|
2
|
+
|
|
3
|
+
function hostnameFromURL(url: string | URL | RequestInfo): string | null {
|
|
4
|
+
try {
|
|
5
|
+
if (typeof url === 'string') {
|
|
6
|
+
return new URL(url).hostname
|
|
7
|
+
}
|
|
8
|
+
if ('url' in url) {
|
|
9
|
+
return new URL(url.url).hostname
|
|
10
|
+
}
|
|
11
|
+
return url.hostname
|
|
12
|
+
} catch {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isHostOnDenyList(url: string | URL | Request, options: NetworkRecordOptions) {
|
|
18
|
+
const hostname = hostnameFromURL(url)
|
|
19
|
+
const defaultNotDenied = { hostname, isHostDenied: false }
|
|
20
|
+
|
|
21
|
+
if (!options.payloadHostDenyList?.length || !hostname?.trim().length) {
|
|
22
|
+
return defaultNotDenied
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const deny of options.payloadHostDenyList) {
|
|
26
|
+
if (hostname.endsWith(deny)) {
|
|
27
|
+
return { hostname, isHostDenied: true }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return defaultNotDenied
|
|
32
|
+
}
|