mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
[web] analytics for flaky connections (#35916)
* emit connection-restored events * emit post-offline sync success/failure events GitOrigin-RevId: 3fce5f328d726aeb5edf39f14239ef6e5bda2f99
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import customSessionStorage from '@/infrastructure/session-storage'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const KEY_PREFIX = 'conn.outage.'
|
||||
|
||||
export type OutageResolution = 'reconnected' | 'saved' | 'out-of-sync'
|
||||
|
||||
export type ConnectionOutageRecord = {
|
||||
projectId: string
|
||||
detectedAt: number
|
||||
oldestUnsavedOpAt: number | null
|
||||
pendingChars: number
|
||||
inflightChars: number
|
||||
updatedAt: number
|
||||
teardownAt?: number
|
||||
}
|
||||
|
||||
export class ConnectionOutageTracker {
|
||||
static buildKey(projectId: string): string {
|
||||
const userId = getMeta('ol-user_id') ?? 'anonymous'
|
||||
return `${KEY_PREFIX}${userId}.${projectId}`
|
||||
}
|
||||
|
||||
static read(projectId: string): ConnectionOutageRecord | null {
|
||||
return customSessionStorage.getItem(this.buildKey(projectId))
|
||||
}
|
||||
|
||||
static remove(projectId: string): void {
|
||||
customSessionStorage.removeItem(this.buildKey(projectId))
|
||||
}
|
||||
|
||||
private static write(record: ConnectionOutageRecord): void {
|
||||
customSessionStorage.setItem(this.buildKey(record.projectId), record)
|
||||
}
|
||||
|
||||
// Sweep every user's records on logout. sessionStorage is per-tab and can't
|
||||
// be reached from the tab that handles logout for any other tab, so we clear
|
||||
// the whole prefix here rather than scoping to the current user.
|
||||
static clearAll(): void {
|
||||
customSessionStorage.removeByPrefix(KEY_PREFIX)
|
||||
}
|
||||
|
||||
// Begin an outage. No-op if a record already exists, preserving the original
|
||||
// timestamps (epoch ms) across reloads and repeated stalled ticks.
|
||||
static start(projectId: string, oldestUnsavedOpAt: number | null): void {
|
||||
if (this.read(projectId)) {
|
||||
return
|
||||
}
|
||||
this.write({
|
||||
projectId,
|
||||
detectedAt: Date.now(),
|
||||
oldestUnsavedOpAt,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot current unsaved edit sizes while offline. Ops flush on reconnect,
|
||||
// so the live sizes are gone by restore time; persist them here.
|
||||
//
|
||||
// oldestUnsavedOpAt is backfilled the first time an op appears mid-outage:
|
||||
// START runs before the user has typed on an idle drop, so the field is null
|
||||
// then, and unsavedDurationMs would otherwise stay zero even once edits pile up.
|
||||
static recordEdits(
|
||||
projectId: string,
|
||||
pendingChars: number,
|
||||
inflightChars: number,
|
||||
oldestUnsavedOpAt: number | null
|
||||
): void {
|
||||
const record = this.read(projectId)
|
||||
// A torn-down record is final: its edit sizes belong to the teardown moment,
|
||||
// and its updatedAt has to stop moving so the staleness cap can expire it.
|
||||
// The stall never clears after a teardown, so the snapshot interval keeps
|
||||
// running and would otherwise overwrite both.
|
||||
if (!record || record.teardownAt !== undefined) {
|
||||
return
|
||||
}
|
||||
// Each field only ever grows during a genuine stall, because ops cannot
|
||||
// drain without a working connection. A drop therefore means the recovery
|
||||
// flush has begun and the snapshot is measuring that, not the outage: an ack
|
||||
// nulls the inflight op a flush delay before the next pending op is
|
||||
// promoted, so a snapshot landing in that gap reads zero inflight while
|
||||
// pending is still full.
|
||||
record.pendingChars = Math.max(record.pendingChars, pendingChars)
|
||||
record.inflightChars = Math.max(record.inflightChars, inflightChars)
|
||||
if (record.oldestUnsavedOpAt === null && oldestUnsavedOpAt !== null) {
|
||||
record.oldestUnsavedOpAt = oldestUnsavedOpAt
|
||||
}
|
||||
record.updatedAt = Date.now()
|
||||
this.write(record)
|
||||
}
|
||||
|
||||
// Mark an outage as ended by a terminal out-of-sync teardown. Reporting is
|
||||
// left to the hook, which flushes the record as soon as it sees the teardown
|
||||
// and again on the next load, since a teardown that happens while offline
|
||||
// cannot tell that its report was dropped.
|
||||
//
|
||||
// Upserts, because a doc error unrelated to connectivity gets here before any
|
||||
// stall is detected. Those records have no unsaved op timestamp, which is what
|
||||
// separates them from a genuine outage.
|
||||
static recordTeardown(projectId: string): void {
|
||||
const record = this.read(projectId) ?? {
|
||||
projectId,
|
||||
detectedAt: Date.now(),
|
||||
oldestUnsavedOpAt: null,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
if (record.teardownAt !== undefined) {
|
||||
return
|
||||
}
|
||||
record.teardownAt = Date.now()
|
||||
record.updatedAt = Date.now()
|
||||
this.write(record)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ function getOpSize(op: AnyOperation) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function getShareJsOpSize(shareJsOp: ShareJsOperation) {
|
||||
export function getShareJsOpSize(shareJsOp: ShareJsOperation) {
|
||||
return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Migrated from static methods of Document in Document.js
|
||||
|
||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import {
|
||||
DocumentContainer,
|
||||
getShareJsOpSize,
|
||||
} from '@/features/ide-react/editor/document-container'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { Socket } from '@/features/ide-react/connection/types/socket'
|
||||
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
|
||||
@@ -45,18 +48,37 @@ export class OpenDocuments {
|
||||
const docs = this.unsavedDocs()
|
||||
let pendingOpsLength = 0
|
||||
let inflightOpsLength = 0
|
||||
let pendingChars = 0
|
||||
let inflightChars = 0
|
||||
for (const doc of docs) {
|
||||
const pendingOp = doc.getPendingOp()
|
||||
const inFlightOp = doc.getInflightOp()
|
||||
pendingOpsLength += pendingOp?.length || 0
|
||||
inflightOpsLength += inFlightOp?.length || 0
|
||||
pendingChars += pendingOp ? getShareJsOpSize(pendingOp) : 0
|
||||
inflightChars += inFlightOp ? getShareJsOpSize(inFlightOp) : 0
|
||||
}
|
||||
return {
|
||||
pendingOpsLength,
|
||||
inflightOpsLength,
|
||||
pendingChars,
|
||||
inflightChars,
|
||||
}
|
||||
}
|
||||
|
||||
// performance.now() timeline, matching the unsaved-docs alert
|
||||
getOldestUnsavedOpCreatedAt(): number | null {
|
||||
let oldest: number | null = null
|
||||
for (const doc of this.unsavedDocs()) {
|
||||
const createdAt =
|
||||
doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
|
||||
if (createdAt != null && (oldest === null || createdAt < oldest)) {
|
||||
oldest = createdAt
|
||||
}
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
|
||||
private createDoc(docId: string) {
|
||||
const doc = new DocumentContainer(
|
||||
docId,
|
||||
|
||||
Reference in New Issue
Block a user