redesgin
This commit is contained in:
1 parent
21f04d696b
commit
5792e9c037
16 files changed
+2101
-2109
No files matched your search
@@ -0,0 +1,356 @@
|
||||
// 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
|
||||
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<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,
|
||||
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<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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user