musora-content-services 2.89.0 → 2.92.3
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/CHANGELOG.md +31 -0
- package/docs/ContentOrganization.html +2 -2
- package/docs/Forums.html +2 -2
- package/docs/Gamification.html +2 -2
- package/docs/TestUser.html +2 -2
- package/docs/UserManagementSystem.html +2 -2
- package/docs/api_types.js.html +2 -2
- package/docs/config.js.html +2 -2
- package/docs/content-org_content-org.js.html +2 -2
- package/docs/content-org_guided-courses.ts.html +2 -2
- package/docs/content-org_learning-paths.ts.html +52 -40
- package/docs/content-org_playlists-types.js.html +2 -2
- package/docs/content-org_playlists.js.html +2 -2
- package/docs/content.js.html +2 -2
- package/docs/content_artist.ts.html +2 -2
- package/docs/content_genre.ts.html +2 -2
- package/docs/content_instructor.ts.html +2 -2
- package/docs/forums_categories.ts.html +2 -2
- package/docs/forums_forums.ts.html +2 -2
- package/docs/forums_posts.ts.html +2 -2
- package/docs/forums_threads.ts.html +2 -2
- package/docs/gamification_awards.ts.html +2 -2
- package/docs/gamification_gamification.js.html +2 -2
- package/docs/global.html +2 -2
- package/docs/index.html +2 -2
- package/docs/liveTesting.ts.html +2 -2
- package/docs/module-Accounts.html +2 -2
- package/docs/module-Artist.html +2 -2
- package/docs/module-Awards.html +2 -2
- package/docs/module-Config.html +2 -2
- package/docs/module-Content-Services-V2.html +2 -2
- package/docs/module-Forums.html +2 -2
- package/docs/module-Genre.html +2 -2
- package/docs/module-GuidedCourses.html +2 -2
- package/docs/module-Instructor.html +2 -2
- package/docs/module-Interests.html +2 -2
- package/docs/module-LearningPaths.html +269 -143
- package/docs/module-Onboarding.html +3 -3
- package/docs/module-Payments.html +2 -2
- package/docs/module-Permissions.html +2 -2
- package/docs/module-Playlists.html +2 -2
- package/docs/module-ProgressRow.html +2 -2
- package/docs/module-Railcontent-Services.html +34 -893
- package/docs/module-Sanity-Services.html +2 -2
- package/docs/module-Sessions.html +2 -2
- package/docs/module-UserActivity.html +70 -116
- package/docs/module-UserChat.html +2 -2
- package/docs/module-UserManagement.html +2 -2
- package/docs/module-UserMemberships.html +2 -2
- package/docs/module-UserNotifications.html +2 -2
- package/docs/module-UserProfile.html +2 -2
- package/docs/progress-row_method-card.js.html +3 -2
- package/docs/railcontent.js.html +14 -137
- package/docs/sanity.js.html +2 -2
- package/docs/userActivity.js.html +85 -150
- package/docs/user_account.ts.html +2 -2
- package/docs/user_chat.js.html +2 -2
- package/docs/user_interests.js.html +2 -2
- package/docs/user_management.js.html +2 -2
- package/docs/user_memberships.ts.html +2 -2
- package/docs/user_notifications.js.html +2 -2
- package/docs/user_onboarding.ts.html +10 -6
- package/docs/user_payments.ts.html +2 -2
- package/docs/user_permissions.js.html +2 -2
- package/docs/user_profile.js.html +2 -2
- package/docs/user_sessions.js.html +2 -2
- package/docs/user_types.js.html +2 -2
- package/docs/user_user-management-system.js.html +2 -2
- package/package.json +11 -3
- package/src/contentTypeConfig.js +6 -0
- package/src/index.d.ts +7 -31
- package/src/index.js +10 -34
- package/src/services/content-org/learning-paths.ts +31 -0
- package/src/services/contentAggregator.js +2 -2
- package/src/services/contentLikes.js +6 -39
- package/src/services/contentProgress.js +181 -479
- package/src/services/dataContext.js +0 -2
- package/src/services/progress-row/method-card.js +1 -0
- package/src/services/railcontent.js +12 -135
- package/src/services/sentry/.indexignore +0 -0
- package/src/services/sentry/index.ts +23 -0
- package/src/services/sync/.indexignore +0 -0
- package/src/services/sync/adapters/factory.ts +26 -0
- package/src/services/sync/adapters/lokijs.ts +1 -0
- package/src/services/sync/adapters/sqlite.ts +1 -0
- package/src/services/sync/concurrency-safety.ts +4 -0
- package/src/services/sync/context/index.ts +43 -0
- package/src/services/sync/context/providers/base.ts +4 -0
- package/src/services/sync/context/providers/connectivity.ts +14 -0
- package/src/services/sync/context/providers/durability.ts +5 -0
- package/src/services/sync/context/providers/index.ts +5 -0
- package/src/services/sync/context/providers/session.ts +8 -0
- package/src/services/sync/context/providers/tabs.ts +18 -0
- package/src/services/sync/context/providers/visibility.ts +14 -0
- package/src/services/sync/database/factory.ts +10 -0
- package/src/services/sync/errors/boundary.ts +45 -0
- package/src/services/sync/errors/index.ts +49 -0
- package/src/services/sync/fetch.ts +310 -0
- package/src/services/sync/index.ts +80 -0
- package/src/services/sync/manager.ts +139 -0
- package/src/services/sync/models/Base.ts +47 -0
- package/src/services/sync/models/ContentLike.ts +16 -0
- package/src/services/sync/models/ContentProgress.ts +69 -0
- package/src/services/sync/models/Practice.ts +72 -0
- package/src/services/sync/models/PracticeDayNote.ts +23 -0
- package/src/services/sync/models/index.ts +4 -0
- package/src/services/sync/repositories/base.ts +247 -0
- package/src/services/sync/repositories/content-likes.ts +26 -0
- package/src/services/sync/repositories/content-progress.ts +160 -0
- package/src/services/sync/repositories/index.ts +4 -0
- package/src/services/sync/repositories/practice-day-notes.ts +4 -0
- package/src/services/sync/repositories/practices.ts +52 -0
- package/src/services/sync/repository-proxy.ts +48 -0
- package/src/services/sync/resolver.ts +84 -0
- package/src/services/sync/retry.ts +88 -0
- package/src/services/sync/run-scope.ts +30 -0
- package/src/services/sync/schema/index.ts +66 -0
- package/src/services/sync/serializers/index.ts +2 -0
- package/src/services/sync/serializers/model.ts +32 -0
- package/src/services/sync/serializers/raw.ts +21 -0
- package/src/services/sync/store/index.ts +779 -0
- package/src/services/sync/store/push-coalescer.ts +57 -0
- package/src/services/sync/store-configs.ts +41 -0
- package/src/services/sync/strategies/base.ts +21 -0
- package/src/services/sync/strategies/index.ts +12 -0
- package/src/services/sync/strategies/initial.ts +11 -0
- package/src/services/sync/strategies/polling.ts +54 -0
- package/src/services/sync/telemetry/index.ts +140 -0
- package/src/services/sync/telemetry/sampling.ts +91 -0
- package/src/services/sync/utils/event-emitter.ts +24 -0
- package/src/services/sync/utils/index.ts +1 -0
- package/src/services/sync/utils/throttle.ts +93 -0
- package/src/services/sync/utils/timers.ts +9 -0
- package/src/services/userActivity.js +83 -148
- package/test/contentProgress.test.js +6 -39
- package/test/live/contentProgressLive.test.js +2 -31
- package/tools/generate-index.cjs +10 -4
- package/.claude/settings.local.json +0 -8
- package/babel.config.cjs +0 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import BaseModel from "../models/Base"
|
|
2
|
+
import { RecordId } from "@nozbe/watermelondb"
|
|
3
|
+
import { EpochMs } from ".."
|
|
4
|
+
import { SyncPushResponse } from "../fetch"
|
|
5
|
+
|
|
6
|
+
type PushIntent = {
|
|
7
|
+
promise: Promise<SyncPushResponse>
|
|
8
|
+
records: {
|
|
9
|
+
id: RecordId
|
|
10
|
+
updatedAt: EpochMs
|
|
11
|
+
}[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default class PushCoalescer {
|
|
15
|
+
private intents: PushIntent[]
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
this.intents = []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<SyncPushResponse>) {
|
|
22
|
+
const found = this.find(records)
|
|
23
|
+
|
|
24
|
+
if (found) {
|
|
25
|
+
return found.promise
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return this.add(pusher(records), records)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
|
|
32
|
+
const intent = {
|
|
33
|
+
promise,
|
|
34
|
+
records: records.map(record => ({
|
|
35
|
+
id: record.id,
|
|
36
|
+
updatedAt: record.updated_at
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cleanup = () => this.intents.splice(this.intents.indexOf(intent), 1)
|
|
41
|
+
intent.promise.finally(cleanup)
|
|
42
|
+
|
|
43
|
+
this.intents.push(intent)
|
|
44
|
+
|
|
45
|
+
return intent.promise
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private find(records: BaseModel[]) {
|
|
49
|
+
return this.intents.find(intent => {
|
|
50
|
+
return records.every(record => {
|
|
51
|
+
return intent.records.find(({ id, updatedAt }) => {
|
|
52
|
+
return id === record.id && updatedAt >= record.updated_at
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SyncStoreConfig } from "./store"
|
|
2
|
+
import { ContentLike, ContentProgress, Practice, PracticeDayNote } from "./models"
|
|
3
|
+
import { handlePull, handlePush, makeFetchRequest } from "./fetch"
|
|
4
|
+
|
|
5
|
+
import type SyncStore from "./store"
|
|
6
|
+
import type BaseModel from "./models/Base"
|
|
7
|
+
|
|
8
|
+
export default function createStoresFromConfig(createStore: <TModel extends BaseModel>(config: SyncStoreConfig<TModel>) => SyncStore<TModel>) {
|
|
9
|
+
return [
|
|
10
|
+
createStore({
|
|
11
|
+
model: ContentLike,
|
|
12
|
+
pull: handlePull(makeFetchRequest('/api/content/v1/user/likes')),
|
|
13
|
+
push: handlePush(makeFetchRequest('/api/content/v1/user/likes', { method: 'POST' })),
|
|
14
|
+
}),
|
|
15
|
+
|
|
16
|
+
createStore({
|
|
17
|
+
model: ContentProgress,
|
|
18
|
+
comparator: (server, local) => {
|
|
19
|
+
if (server.record.progress_percent === 0 || local.progress_percent === 0) {
|
|
20
|
+
return server.meta.lifecycle.updated_at >= local.updated_at ? 'SERVER' : 'LOCAL'
|
|
21
|
+
} else {
|
|
22
|
+
return server.record.progress_percent >= local.progress_percent ? 'SERVER' : 'LOCAL'
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
pull: handlePull(makeFetchRequest('/content/user/progress')),
|
|
26
|
+
push: handlePush(makeFetchRequest('/content/user/progress', { method: 'POST' })),
|
|
27
|
+
}),
|
|
28
|
+
|
|
29
|
+
createStore({
|
|
30
|
+
model: Practice,
|
|
31
|
+
pull: handlePull(makeFetchRequest('/api/user/practices/v1')),
|
|
32
|
+
push: handlePush(makeFetchRequest('/api/user/practices/v1', { method: 'POST' })),
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
createStore({
|
|
36
|
+
model: PracticeDayNote,
|
|
37
|
+
pull: handlePull(makeFetchRequest('/api/user/practices/v1/notes')),
|
|
38
|
+
push: handlePush(makeFetchRequest('/api/user/practices/v1/notes', { method: 'POST' })),
|
|
39
|
+
})
|
|
40
|
+
] as unknown as SyncStore<BaseModel>[]
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { SyncStrategy } from "./index";
|
|
2
|
+
import SyncContext from "../context";
|
|
3
|
+
import SyncStore from "../store";
|
|
4
|
+
|
|
5
|
+
export default abstract class BaseSyncStrategy implements SyncStrategy {
|
|
6
|
+
protected registry: { callback: (reason: string) => void, store: SyncStore }[] = []
|
|
7
|
+
abstract start(): void
|
|
8
|
+
abstract stop(): void
|
|
9
|
+
|
|
10
|
+
protected context: SyncContext
|
|
11
|
+
|
|
12
|
+
constructor(context: SyncContext) {
|
|
13
|
+
this.context = context
|
|
14
|
+
this.registry = []
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
onTrigger(store: SyncStore, callback: (reason: string) => void): this {
|
|
18
|
+
this.registry.push({ callback, store })
|
|
19
|
+
return this
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import SyncStore from "../store";
|
|
2
|
+
|
|
3
|
+
export interface SyncStrategy {
|
|
4
|
+
start(): void
|
|
5
|
+
stop(): void
|
|
6
|
+
|
|
7
|
+
onTrigger(store: SyncStore, callback: (reason: string) => void): this
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { default as BaseStrategy } from './base'
|
|
11
|
+
export { default as InitialStrategy } from './initial'
|
|
12
|
+
export { default as PollingStrategy } from './polling'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DefaultTimers, Timers } from "../utils/timers";
|
|
2
|
+
import { BaseStrategy } from "./index";
|
|
3
|
+
import SyncContext from "../context";
|
|
4
|
+
import SyncStore from "../store";
|
|
5
|
+
|
|
6
|
+
export default class PollingStrategy extends BaseStrategy {
|
|
7
|
+
private active = false
|
|
8
|
+
private storeTimers = new Map<SyncStore, any>()
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
context: SyncContext,
|
|
12
|
+
private intervalMs: number,
|
|
13
|
+
private readonly timers: Timers = DefaultTimers
|
|
14
|
+
) {
|
|
15
|
+
super(context)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
start() {
|
|
19
|
+
if (this.active) return
|
|
20
|
+
this.active = true
|
|
21
|
+
|
|
22
|
+
this.registry.forEach(({ store, callback }) => {
|
|
23
|
+
store.on('pullCompleted', () => {
|
|
24
|
+
this.resetTimer(store, callback)
|
|
25
|
+
})
|
|
26
|
+
this.startTimer(store, callback)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
stop() {
|
|
31
|
+
this.active = false
|
|
32
|
+
this.storeTimers.forEach(timerId => this.timers.clearTimeout(timerId))
|
|
33
|
+
this.storeTimers.clear()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private startTimer(store: SyncStore, callback: (reason: string) => void) {
|
|
37
|
+
this.timers.clearTimeout(this.storeTimers.get(store))
|
|
38
|
+
|
|
39
|
+
const timerId = this.timers.setTimeout(() => {
|
|
40
|
+
if (this.context.visibility.getValue()) {
|
|
41
|
+
callback('polling')
|
|
42
|
+
}
|
|
43
|
+
this.startTimer(store, callback)
|
|
44
|
+
}, this.intervalMs)
|
|
45
|
+
|
|
46
|
+
this.storeTimers.set(store, timerId)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private resetTimer(store: SyncStore, callback: (reason: string) => void) {
|
|
50
|
+
if (this.active) {
|
|
51
|
+
this.startTimer(store, callback)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import watermelonLogger from '@nozbe/watermelondb/utils/common/logger'
|
|
2
|
+
import { SyncError, SyncUnexpectedError } from '../errors'
|
|
3
|
+
|
|
4
|
+
import * as InjectedSentry from '@sentry/browser'
|
|
5
|
+
export type SentryBrowserOptions = NonNullable<Parameters<typeof InjectedSentry.init>[0]>;
|
|
6
|
+
|
|
7
|
+
export type SentryLike = {
|
|
8
|
+
captureException: typeof InjectedSentry.captureException
|
|
9
|
+
addBreadcrumb: typeof InjectedSentry.addBreadcrumb
|
|
10
|
+
startSpan: typeof InjectedSentry.startSpan
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type StartSpanOptions = Parameters<typeof InjectedSentry.startSpan>[0]
|
|
14
|
+
export type Span = InjectedSentry.Span
|
|
15
|
+
|
|
16
|
+
export const SYNC_TELEMETRY_TRACE_PREFIX = 'sync:'
|
|
17
|
+
|
|
18
|
+
export class SyncTelemetry {
|
|
19
|
+
private static instance: SyncTelemetry | null = null
|
|
20
|
+
|
|
21
|
+
public static setInstance(instance: SyncTelemetry): SyncTelemetry {
|
|
22
|
+
SyncTelemetry.instance = instance
|
|
23
|
+
return instance
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public static getInstance(): SyncTelemetry | null {
|
|
27
|
+
return SyncTelemetry.instance
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public static clearInstance(): void {
|
|
31
|
+
SyncTelemetry.instance = null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private userId: string
|
|
35
|
+
private Sentry: SentryLike;
|
|
36
|
+
|
|
37
|
+
// allows us to know if Sentry shouldn't double-capture a dev-prettified console.error log
|
|
38
|
+
private _ignoreConsole = false
|
|
39
|
+
|
|
40
|
+
constructor(userId: string, { Sentry }: { Sentry: SentryLike }) {
|
|
41
|
+
this.userId = userId
|
|
42
|
+
this.Sentry = Sentry
|
|
43
|
+
watermelonLogger.log = (...messages: any[]) => this.log('[Watermelon]', ...messages);
|
|
44
|
+
watermelonLogger.warn = (...messages: any[]) => this.warn('[Watermelon]', ...messages);
|
|
45
|
+
watermelonLogger.error = (...messages: any[]) => this.error('[Watermelon]', ...messages);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
trace<T>(opts: StartSpanOptions, callback: (_span: Span) => T) {
|
|
49
|
+
const options = {
|
|
50
|
+
...opts,
|
|
51
|
+
name: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.name}`,
|
|
52
|
+
op: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.op}`,
|
|
53
|
+
attributes: {
|
|
54
|
+
...opts.attributes,
|
|
55
|
+
userId: this.userId
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return this.Sentry.startSpan<T>(options, (span) => {
|
|
59
|
+
let desc = span['_spanId'].slice(0, 4)
|
|
60
|
+
desc += span['_parentSpanId'] ? ` (< ${span['_parentSpanId'].slice(0, 4)})` : ''
|
|
61
|
+
|
|
62
|
+
this.debug(`[trace:start] ${options.name} (${desc})`)
|
|
63
|
+
const result = callback(span)
|
|
64
|
+
Promise.resolve(result).finally(() => this.debug(`[trace:end] ${options.name} (${desc})`))
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
capture(err: SyncError) {
|
|
71
|
+
err.markReported()
|
|
72
|
+
this.Sentry.captureException(err, err instanceof SyncUnexpectedError ? {
|
|
73
|
+
mechanism: {
|
|
74
|
+
handled: false
|
|
75
|
+
}
|
|
76
|
+
} : undefined)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
this._ignoreConsole = true
|
|
80
|
+
this.error(err.message)
|
|
81
|
+
this._ignoreConsole = false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// allows us to know if Sentry shouldn't double-capture a dev-prettified console.error log
|
|
85
|
+
shouldIgnoreConsole() {
|
|
86
|
+
return this._ignoreConsole
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
debug(...messages: any[]) {
|
|
90
|
+
console.debug(...this.formattedConsoleMessages(...messages));
|
|
91
|
+
this.recordBreadcrumb('debug', ...messages)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
info(...messages: any[]) {
|
|
95
|
+
console.info(...this.formattedConsoleMessages(...messages));
|
|
96
|
+
this.recordBreadcrumb('info', ...messages)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
log(...messages: any[]) {
|
|
100
|
+
console.log(...this.formattedConsoleMessages(...messages));
|
|
101
|
+
this.recordBreadcrumb('log', ...messages)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
warn(...messages: any[]) {
|
|
105
|
+
console.warn(...this.formattedConsoleMessages(...messages));
|
|
106
|
+
this.recordBreadcrumb('warning', ...messages)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
error(...messages: any[]) {
|
|
110
|
+
console.error(...this.formattedConsoleMessages(...messages));
|
|
111
|
+
this.recordBreadcrumb('error', ...messages)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fatal(...messages: any[]) {
|
|
115
|
+
console.error(...this.formattedConsoleMessages(...messages));
|
|
116
|
+
this.recordBreadcrumb('fatal', ...messages)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private recordBreadcrumb(level: InjectedSentry.Breadcrumb['level'], ...messages: any[]) {
|
|
120
|
+
this.Sentry.addBreadcrumb({
|
|
121
|
+
message: messages.join(', '),
|
|
122
|
+
level,
|
|
123
|
+
category: 'sync',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private formattedConsoleMessages(...messages: any[]) {
|
|
128
|
+
const date = new Date();
|
|
129
|
+
return [...this.consolePrefix(date), ...messages, ...this.consoleSuffix(date)];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private consolePrefix(date: Date) {
|
|
133
|
+
const now = Math.round(date.getTime() / 1000).toString();
|
|
134
|
+
return [`📡 SYNC: (%c${now.slice(0, 5)}%c${now.slice(5, 10)})`, 'color: #ccc', 'font-weight: bold;'];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private consoleSuffix(date: Date) {
|
|
138
|
+
return [` [${date.toLocaleTimeString()}, ${date.getTime()}]`];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SyncTelemetry, SYNC_TELEMETRY_TRACE_PREFIX, type SentryBrowserOptions } from '.'
|
|
2
|
+
import { SyncError } from '../errors'
|
|
3
|
+
|
|
4
|
+
type ReturnsUndefined<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T> | undefined
|
|
5
|
+
|
|
6
|
+
export const syncSentryBeforeSend: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSend']>> = (event, hint) => {
|
|
7
|
+
if (event.logger === 'console' && SyncTelemetry.getInstance().shouldIgnoreConsole()) {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (hint?.originalException instanceof SyncError) {
|
|
12
|
+
if (hint.originalException.isReported()) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// populate event with error data Sentry would otherwise ignore
|
|
17
|
+
event.extra = {
|
|
18
|
+
...event.extra,
|
|
19
|
+
details: hint.originalException.getDetails()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return event
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const syncSentryBeforeSendTransaction: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSendTransaction']>> = (event, hint) => {
|
|
29
|
+
if (event.contexts?.trace?.op?.startsWith(SYNC_TELEMETRY_TRACE_PREFIX)) {
|
|
30
|
+
// filter out noisy empty sync traces
|
|
31
|
+
if (event.contexts.trace.op === `${SYNC_TELEMETRY_TRACE_PREFIX}sync` && event.spans?.length === 0) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// sentry doesn't bother to expose your chosen environment in tracesSampler
|
|
40
|
+
// so we have to make consumers pass in our greedy option
|
|
41
|
+
export const createSyncSentryTracesSampler = (greedy = false) => {
|
|
42
|
+
const sampler: ReturnsUndefined<NonNullable<SentryBrowserOptions['tracesSampler']>> = (context) => {
|
|
43
|
+
if (!context.name.startsWith(SYNC_TELEMETRY_TRACE_PREFIX)) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (greedy) {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { parentSampled, attributes } = context
|
|
52
|
+
|
|
53
|
+
if (parentSampled) {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (attributes?.userId) {
|
|
58
|
+
return userBucketedSampler(attributes.userId as string | number, 0.1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return sampler
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns a sampling decision (0 or 1) based on user ID and rotation period.
|
|
69
|
+
*
|
|
70
|
+
* @param userId - string or number uniquely identifying the user
|
|
71
|
+
* @param sampleRate - fraction of users to include (0..1)
|
|
72
|
+
* @param rotationPeriodMs - rotation period in milliseconds (e.g., 1 day, 1 week)
|
|
73
|
+
*/
|
|
74
|
+
function userBucketedSampler(
|
|
75
|
+
userId: string | number,
|
|
76
|
+
sampleRate: number,
|
|
77
|
+
rotationPeriodMs: number = 7 * 24 * 60 * 60 * 1000 // default: 1 week
|
|
78
|
+
): 0 | 1 {
|
|
79
|
+
const uid = typeof userId === 'string' ? hashString(userId) : userId;
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const bucket = (uid + Math.floor(now / rotationPeriodMs)) % 100;
|
|
82
|
+
return bucket < sampleRate * 100 ? 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hashString(str: string): number {
|
|
86
|
+
let hash = 0;
|
|
87
|
+
for (let i = 0; i < str.length; i++) {
|
|
88
|
+
hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
|
|
89
|
+
}
|
|
90
|
+
return hash;
|
|
91
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type EventMap = Record<string, any[]>;
|
|
2
|
+
|
|
3
|
+
export default class EventEmitter<Events extends EventMap> {
|
|
4
|
+
private events: {
|
|
5
|
+
[K in keyof Events]?: Array<(...args: Events[K]) => void>
|
|
6
|
+
} = {};
|
|
7
|
+
|
|
8
|
+
on<K extends keyof Events>(event: K, fn: (...args: Events[K]) => void): () => void {
|
|
9
|
+
(this.events[event] ||= []).push(fn);
|
|
10
|
+
return () => this.off(event, fn);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
off<K extends keyof Events>(event: K, fn?: (...args: Events[K]) => void) {
|
|
14
|
+
if (!fn) {
|
|
15
|
+
delete this.events[event];
|
|
16
|
+
} else {
|
|
17
|
+
this.events[event] = this.events[event]?.filter(f => f !== fn) || [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
emit<K extends keyof Events>(event: K, ...args: Events[K]) {
|
|
22
|
+
this.events[event]?.forEach(fn => fn(...args));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './throttle'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface ThrottleState<T> {
|
|
2
|
+
isWaiting: boolean
|
|
3
|
+
current: Promise<T> | null;
|
|
4
|
+
next: (() => Promise<T>) | null;
|
|
5
|
+
lastCallTime: number;
|
|
6
|
+
minIntervalMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createThrottleState<T>(minIntervalMs: number): ThrottleState<T> {
|
|
10
|
+
return {
|
|
11
|
+
isWaiting: false,
|
|
12
|
+
current: null,
|
|
13
|
+
next: null,
|
|
14
|
+
lastCallTime: 0,
|
|
15
|
+
minIntervalMs
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function dropThrottle<T>(
|
|
20
|
+
options: { state: ThrottleState<T>, deferOnce?: boolean },
|
|
21
|
+
fn: (..._args: any[]) => Promise<T>
|
|
22
|
+
) {
|
|
23
|
+
return (...args: any[]) => {
|
|
24
|
+
const { state } = options
|
|
25
|
+
|
|
26
|
+
const wait = () => {
|
|
27
|
+
return new Promise<void>(resolve => {
|
|
28
|
+
state.isWaiting = true
|
|
29
|
+
const t = Date.now() - state.lastCallTime
|
|
30
|
+
if (t < state.minIntervalMs) {
|
|
31
|
+
setTimeout(resolve, state.minIntervalMs - t)
|
|
32
|
+
} else {
|
|
33
|
+
resolve()
|
|
34
|
+
}
|
|
35
|
+
}).finally(() => {
|
|
36
|
+
state.isWaiting = false
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const run = (fn: (..._args: any[]) => Promise<T>, args: any[]) => {
|
|
41
|
+
state.lastCallTime = Date.now()
|
|
42
|
+
return fn(...args).finally(() => {
|
|
43
|
+
if (state.next) {
|
|
44
|
+
const n = state.next()
|
|
45
|
+
state.next = null
|
|
46
|
+
state.current = n
|
|
47
|
+
} else {
|
|
48
|
+
state.current = null
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!state.current) {
|
|
54
|
+
state.current = wait().then(() => run(fn, args))
|
|
55
|
+
} else if (options.deferOnce && !state.isWaiting) {
|
|
56
|
+
state.next = () => run(fn, args)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return state.current
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function queueThrottle<T>(
|
|
64
|
+
options: { state: ThrottleState<T>, deferOnce?: boolean },
|
|
65
|
+
fn: (..._args: any[]) => Promise<T>
|
|
66
|
+
) {
|
|
67
|
+
return (...args: any[]) => {
|
|
68
|
+
const { state } = options
|
|
69
|
+
|
|
70
|
+
const run = async () => {
|
|
71
|
+
const elapsed = Date.now() - state.lastCallTime
|
|
72
|
+
if (elapsed < state.minIntervalMs) {
|
|
73
|
+
await new Promise<void>(r => setTimeout(r, state.minIntervalMs - elapsed))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
state.lastCallTime = Date.now()
|
|
77
|
+
return fn(...args).finally(() => {
|
|
78
|
+
const next = state.next
|
|
79
|
+
state.next = null
|
|
80
|
+
state.current = next ? next() : null
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!state.current) {
|
|
85
|
+
state.current = run()
|
|
86
|
+
} else {
|
|
87
|
+
state.next = () => run()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return state.current
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
}
|