// Real-user session grouping and Web Vitals rating, derived entirely from // what otel-rum.js actually sends: no click tracking, no checkout/outcome // signal, and no geo attribute exist in this pipeline, so none of those are // modeled here. See otel-rum.js for the ground truth of what's captured. import { getAttributeValue, getServiceName } from "./otel-utils" import { parseBreadcrumbs, type ParsedBreadcrumb } from "./issue-utils" import type { LogEntry } from "./types" export interface VitalThreshold { good: number needsImprovement: number format: (v: number) => string } // Google Core Web Vitals thresholds. export const VITAL_THRESHOLDS: Record = { LCP: { good: 2500, needsImprovement: 4000, format: (v) => `${(v / 1000).toFixed(2)}s` }, INP: { good: 200, needsImprovement: 500, format: (v) => `${Math.round(v)}ms` }, CLS: { good: 0.1, needsImprovement: 0.25, format: (v) => (Math.round(v * 1000) / 1000).toFixed(3) }, FCP: { good: 1800, needsImprovement: 3000, format: (v) => `${(v / 1000).toFixed(2)}s` }, TTFB: { good: 800, needsImprovement: 1800, format: (v) => `${Math.round(v)}ms` }, } export type VitalRating = "good" | "needs-improvement" | "poor" export function getVitalRating(name: string, value: number): VitalRating { const t = VITAL_THRESHOLDS[name] if (!t) return "good" if (value <= t.good) return "good" if (value <= t.needsImprovement) return "needs-improvement" return "poor" } export interface ParsedUserAgent { browser: string os: string isMobile: boolean } export function parseUserAgent(ua: string): ParsedUserAgent { let browser = "Browser" let os = "OS" const isMobile = /mobile|android|iphone|ipad/i.test(ua) if (/chrome|crios/i.test(ua) && !/edg|opr/i.test(ua)) browser = "Chrome" else if (/safari/i.test(ua) && !/chrome/i.test(ua)) browser = "Safari" else if (/firefox|fxios/i.test(ua)) browser = "Firefox" else if (/edg/i.test(ua)) browser = "Edge" else if (/opr/i.test(ua)) browser = "Opera" if (/windows/i.test(ua)) os = "Windows" else if (/macintosh|mac os x/i.test(ua)) os = "macOS" else if (/android/i.test(ua)) os = "Android" else if (/iphone|ipad|ipod/i.test(ua)) os = "iOS" else if (/linux/i.test(ua)) os = "Linux" return { browser, os, isMobile } } export interface RumErrorEvent { timestamp: number // ms kind: "js" | "http" message: string exceptionType?: string stacktrace?: string httpMethod?: string httpUrl?: string httpStatus?: number traceId?: string } export interface RumTimelineEntry { timestamp: number // ms category: "session-start" | "navigation" | "fetch" | "xhr" | "console.error" | "js-error" | "http-failure" message: string severe: boolean } export interface RumSession { id: string service: string firstSeen: number // ms lastSeen: number // ms userAgent: string device: ParsedUserAgent release?: string environment?: string urls: string[] vitals: Partial> errorCount: number httpFailureCount: number errors: RumErrorEvent[] timeline: RumTimelineEntry[] /** Sort key for "worst experience first" - a transparent sum of real signals, not a fabricated score. */ severityScore: number } interface RawFrontendRecord { timestamp: number service: string sessionId: string userAgent: string release?: string environment?: string path?: string vitalName?: string vitalValue?: number isError: boolean isHttpFailure: boolean exceptionType?: string exceptionMessage?: string stacktrace?: string httpMethod?: string httpUrl?: string httpStatus?: number traceId?: string body: string breadcrumbs: ParsedBreadcrumb[] } function extractFrontendRecords(logs: LogEntry[]): RawFrontendRecord[] { const out: RawFrontendRecord[] = [] logs.forEach((log) => { const resAttrs = log.resource?.attributes ?? [] const service = getServiceName(resAttrs) ;(log.scopeLogs ?? []).forEach((sl) => { const scopeName = sl.scope?.name || "" ;(sl.logRecords ?? []).forEach((r) => { const attrs = r.attributes ?? [] const source = getAttributeValue(attrs, "source") const vitalName = getAttributeValue(attrs, "rum.vital") const browserUrl = getAttributeValue(attrs, "browser.url") const sessionId = getAttributeValue(attrs, "session.id") const isRumScope = scopeName === "otel-rum" const isFrontend = source === "frontend" || isRumScope || Boolean(vitalName) || Boolean(browserUrl) || Boolean(sessionId) if (!isFrontend) return let path: string | undefined if (browserUrl) { try { path = new URL(browserUrl).pathname } catch { path = browserUrl } } const excType = getAttributeValue(attrs, "exception.type") const isHttp = excType === "HttpFailure" const sevNum = r.severityNumber ?? 0 const isErr = (sevNum >= 17 || excType !== "") && !isHttp const vitalValStr = getAttributeValue(attrs, "rum.value") const httpStatusStr = getAttributeValue(attrs, "http.response.status_code") out.push({ timestamp: r.timeUnixNano ? Number(BigInt(r.timeUnixNano) / 1_000_000n) : Date.now(), service, sessionId: sessionId || "anonymous", userAgent: getAttributeValue(attrs, "browser.userAgent"), release: getAttributeValue(attrs, "service.release") || undefined, environment: getAttributeValue(attrs, "service.environment") || undefined, path, vitalName: vitalName || undefined, vitalValue: vitalValStr ? parseFloat(vitalValStr) : undefined, isError: isErr, isHttpFailure: isHttp, exceptionType: excType || undefined, exceptionMessage: getAttributeValue(attrs, "exception.message") || undefined, stacktrace: getAttributeValue(attrs, "exception.stacktrace") || undefined, httpMethod: getAttributeValue(attrs, "http.request.method") || undefined, httpUrl: getAttributeValue(attrs, "url.full") || undefined, httpStatus: httpStatusStr ? parseInt(httpStatusStr, 10) : undefined, traceId: r.traceId || undefined, body: r.body?.stringValue || "", breadcrumbs: parseBreadcrumbs(getAttributeValue(attrs, "breadcrumbs")), }) }) }) }) return out } /** A fetch/xhr breadcrumb whose message ends "-> error" or a 4xx/5xx status. */ function isFailedRequestBreadcrumb(message: string): boolean { return /->\s*(error|[45]\d\d)$/.test(message) } export function buildRumSessions(logs: LogEntry[]): RumSession[] { const records = extractFrontendRecords(logs) const bySession = new Map() for (const r of records) { const list = bySession.get(r.sessionId) ?? [] list.push(r) bySession.set(r.sessionId, list) } const sessions: RumSession[] = [] for (const [id, list] of bySession) { list.sort((a, b) => a.timestamp - b.timestamp) const first = list[0] const last = list[list.length - 1] const urls: string[] = [] const seenUrls = new Set() const vitals: Partial> = {} const errors: RumErrorEvent[] = [] let errorCount = 0 let httpFailureCount = 0 // The record carrying the fullest breadcrumb trail (otel-rum.js sends a // cumulative, capped snapshot with every record, so the longest one is // the most complete picture available for this session). let richestBreadcrumbs: ParsedBreadcrumb[] = [] for (const r of list) { if (r.path && !seenUrls.has(r.path)) { seenUrls.add(r.path) urls.push(r.path) } if (r.vitalName && r.vitalValue !== undefined) { vitals[r.vitalName.toUpperCase()] = r.vitalValue } if (r.isError) { errorCount += 1 errors.push({ timestamp: r.timestamp, kind: "js", message: r.exceptionMessage || r.body || r.exceptionType || "Unhandled error", exceptionType: r.exceptionType, stacktrace: r.stacktrace, traceId: r.traceId, }) } if (r.isHttpFailure) { httpFailureCount += 1 errors.push({ timestamp: r.timestamp, kind: "http", message: r.body || "Request failed", httpMethod: r.httpMethod, httpUrl: r.httpUrl, httpStatus: r.httpStatus, traceId: r.traceId, }) } if (r.breadcrumbs.length > richestBreadcrumbs.length) { richestBreadcrumbs = r.breadcrumbs } } const timeline: RumTimelineEntry[] = [] if (first.path || first.userAgent) { timeline.push({ timestamp: first.timestamp, category: "session-start", message: first.path ? `Session started on ${first.path}` : "Session started", severe: false, }) } for (const crumb of richestBreadcrumbs) { const category = crumb.category as RumTimelineEntry["category"] timeline.push({ timestamp: crumb.t, category: category === "navigation" || category === "fetch" || category === "xhr" || category === "console.error" ? category : "navigation", message: crumb.message, severe: crumb.category === "console.error" || isFailedRequestBreadcrumb(crumb.message), }) } for (const err of errors) { timeline.push({ timestamp: err.timestamp, category: err.kind === "js" ? "js-error" : "http-failure", message: err.message, severe: true, }) } timeline.sort((a, b) => a.timestamp - b.timestamp) const vitalPenalty = (name: string) => { const v = vitals[name] if (v === undefined) return 0 const rating = getVitalRating(name, v) return rating === "poor" ? 2 : rating === "needs-improvement" ? 1 : 0 } sessions.push({ id, service: first.service, firstSeen: first.timestamp, lastSeen: last.timestamp, userAgent: last.userAgent, device: parseUserAgent(last.userAgent), release: last.release, environment: last.environment, urls, vitals, errorCount, httpFailureCount, errors: errors.sort((a, b) => b.timestamp - a.timestamp), timeline, severityScore: errorCount * 3 + httpFailureCount * 2 + vitalPenalty("INP") + vitalPenalty("LCP") + vitalPenalty("CLS"), }) } return sessions.sort((a, b) => b.severityScore - a.severityScore || b.lastSeen - a.lastSeen) } export interface RumAggregates { totalSessions: number sessionsWithErrors: number totalJsErrors: number distinctJsErrorMessages: number totalHttpFailures: number inpP75: number | null } export function aggregateRum(sessions: RumSession[]): RumAggregates { let sessionsWithErrors = 0 let totalJsErrors = 0 let totalHttpFailures = 0 const distinctMessages = new Set() const inpValues: number[] = [] for (const s of sessions) { if (s.errorCount > 0 || s.httpFailureCount > 0) sessionsWithErrors += 1 totalJsErrors += s.errorCount totalHttpFailures += s.httpFailureCount for (const e of s.errors) { if (e.kind === "js") distinctMessages.add(e.message) } if (s.vitals.INP !== undefined) inpValues.push(s.vitals.INP) } inpValues.sort((a, b) => a - b) const inpP75 = inpValues.length > 0 ? inpValues[Math.min(Math.floor(inpValues.length * 0.75), inpValues.length - 1)] : null return { totalSessions: sessions.length, sessionsWithErrors, totalJsErrors, distinctJsErrorMessages: distinctMessages.size, totalHttpFailures, inpP75, } }