Files

105 lines
4.1 KiB
TypeScript

/**
* Anonymous install telemetry shared by the DSH Web UI family plugins.
*
* Once per UTC day per browser, each wired plugin sends one heartbeat to
* the dsh-market edge API listing its package name (and version when known).
* The payload carries no conversation data and no identifiers beyond a random
* UUID generated in localStorage; the server hashes it with a deployment salt
* before storage and never persists IP addresses. Sends are fire-and-forget:
* failures stay silent and simply retry on a later mount or day. The system
* is documented in docs/telemetry.md.
*/
export type TelemetryChannel = 'market' | 'npm' | 'unknown'
export interface TelemetryItem {
/** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
name: string
/** Installed version when known; omitted otherwise. */
version?: string
/** Install channel when determinable (market = Workshop install). */
channel?: TelemetryChannel
}
const VISITOR_KEY = 'dsh-web-ui-telemetry-visitor'
const DAY_KEY_PREFIX = 'dsh-web-ui-telemetry-day:'
const ENDPOINT = 'https://dsh-market.com/api/telemetry/event'
// Baked into each client bundle by shared/tsdown.client.ts (the package's own
// package.json version at build time). Undefined in dev/test builds.
declare const __DSH_PKG_VERSION__: string | undefined
/** The building package's version, when the bundle carries it. */
function bakedVersion(): string | undefined {
try {
return typeof __DSH_PKG_VERSION__ === 'string' && __DSH_PKG_VERSION__ !== ''
? __DSH_PKG_VERSION__
: undefined
} catch {
return undefined
}
}
/** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
function visitorId(): string | null {
try {
const existing = localStorage.getItem(VISITOR_KEY)
if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing
const fresh = crypto.randomUUID().replaceAll('-', '')
localStorage.setItem(VISITOR_KEY, fresh)
return fresh
} catch {
// Storage unavailable (privacy mode, sandboxed frame): report nothing.
return null
}
}
/** Drop stale per-day dedup keys so localStorage does not grow forever. */
function pruneDayKeys(today: string): void {
try {
for (let index = localStorage.length - 1; index >= 0; index -= 1) {
const key = localStorage.key(index)
if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) {
localStorage.removeItem(key)
}
}
} catch { /* best effort */ }
}
/**
* Fire the daily heartbeat for the given items at most once per UTC day per
* browser. Never throws and never blocks the caller. Items without an explicit
* version inherit the bundle's baked build version.
*/
export function reportDailyHeartbeat(items: readonly TelemetryItem[]): void {
try {
if (items.length === 0) return
const today = new Date().toISOString().slice(0, 10)
// Automation contexts (CDP-driven QA browsers, fresh isolated profiles)
// mint a new visitor id per run and would inflate instance counts.
if (navigator.webdriver) return
if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return
const visitor = visitorId()
if (visitor === null) return
pruneDayKeys(today)
const payloadItems = items.map((item) => {
const out: Record<string, string> = { name: item.name }
const version = item.version ?? bakedVersion()
if (version !== undefined) out.version = version
if (item.channel !== undefined) out.channel = item.channel
return out
})
const body = JSON.stringify({ kind: 'heartbeat', visitor, items: payloadItems })
void fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
}).then((response) => {
// Mark the day only after an accepted send, so offline browsers retry
// on a later mount instead of going dark until tomorrow.
if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, '1')
}).catch(() => { /* unreachable endpoint: stay silent */ })
} catch { /* never break the host UI over telemetry */ }
}