Files
go-otel/otel-ui/src/lib/rum-utils.ts
T
mats 97743ea21c
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
sourcemap view
2026-09-19 15:49:44 +02:00

363 lines
12 KiB
TypeScript

// 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<string, VitalThreshold> = {
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
release?: 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
durationMs?: number
traceId?: string
}
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<Record<string, number>>
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<string, RawFrontendRecord[]>()
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<string>()
const vitals: Partial<Record<string, number>> = {}
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,
release: r.release,
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),
durationMs: typeof crumb.d === "number" ? crumb.d : undefined,
traceId: crumb.tid || undefined,
})
}
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<string>()
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,
}
}