This commit is contained in:
mats committed 2026-09-19 09:07:14 +02:00
1 parent 21f04d696b
commit 5792e9c037
16 files changed
+2101 -2109

No files matched your search

+97
View File
@@ -93,3 +93,100 @@ export function sampleContext(sample: IssueSample): string[] {
if (sample.httpStatus) parts.push(String(sample.httpStatus))
return parts
}
/** Severity presentation tone, derived from real triage state - never invented. */
export type SeverityTone = "critical" | "warn" | "ok" | "muted"
export function issueSeverityTone(issue: Issue): SeverityTone {
const status = issueStatusOf(issue)
if (status === "resolved") return "ok"
if (status === "ignored") return "muted"
// Open frontend/span errors block a user-facing request; open log errors
// are noteworthy but not necessarily user-visible, so they read as a
// warning rather than critical.
return issue.kind === "log_error" ? "warn" : "critical"
}
export interface Correlate {
key: string
value: string
share: number
count: number
total: number
}
/**
* The single attribute value that best separates this issue's occurrences -
* i.e. the most common (key, value) pair across its samples. Attributes that
* are present on every sample the same way (say, a route that never varies)
* are still a legitimate correlate; this only reports what's actually there.
*/
export function strongestCorrelate(issue: Issue): Correlate | null {
const tally = new Map<string, { key: string; value: string; count: number }>()
let total = 0
for (const sample of issue.samples) {
const pairs: Array<[string, string | undefined]> = [
["http.route", sample.httpRoute],
["http.status", sample.httpStatus ? String(sample.httpStatus) : undefined],
["release", sample.release],
["environment", sample.environment],
]
let sampleCounted = false
for (const [key, value] of pairs) {
if (!value) continue
sampleCounted = true
const id = `${key}=${value}`
const entry = tally.get(id) ?? { key, value, count: 0 }
entry.count += 1
tally.set(id, entry)
}
if (sampleCounted) total += 1
}
if (total === 0) return null
let best: { key: string; value: string; count: number } | null = null
for (const entry of tally.values()) {
if (!best || entry.count > best.count) best = entry
}
if (!best) return null
return {
key: best.key,
value: best.value,
count: best.count,
total,
share: best.count / total,
}
}
export interface SparklineSeries {
points: TrendPoint[]
max: number
/** Reference level (mean occurrence/hour) drawn as the expected band. */
reference: number
/** Index of the first point that breaches the reference band, if any. */
breachIndex: number | null
}
/**
* Builds the sparkline series for an issue's triage card: the observed
* hourly trend plus a same-data reference line (the mean of the window) so a
* breaching segment can be highlighted. This is a plain statistical baseline
* over real occurrence counts, not a forecast or ML-derived expected band.
*/
export function sparklineSeries(issue: Issue, hours = 24): SparklineSeries {
const points = trendPoints(issue, hours)
const max = Math.max(...points.map((p) => p.count), 1)
const mean = points.reduce((sum, p) => sum + p.count, 0) / points.length
const threshold = Math.max(mean * 1.5, mean + 1)
let breachIndex: number | null = null
for (let i = 0; i < points.length; i++) {
if (points[i].count > threshold) {
breachIndex = i
break
}
}
return { points, max, reference: mean, breachIndex }
}
+356
View File
@@ -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,
}
}
+26
View File
@@ -0,0 +1,26 @@
// Theme is a per-viewer convenience (which palette to render), not app
// state, so it lives in localStorage rather than the observability context.
// Only the redesigned screens (icon rail, top bar, filter bar, triage
// overview) read the ds-* tokens this switches between - see index.css.
const STORAGE_KEY = "otel.ui.theme"
export type ThemeMode = "dark" | "light"
export function getTheme(): ThemeMode {
if (typeof document === "undefined") return "dark"
return document.documentElement.dataset.theme === "light" ? "light" : "dark"
}
export function setTheme(theme: ThemeMode): void {
if (typeof document === "undefined") return
if (theme === "light") {
document.documentElement.dataset.theme = "light"
} else {
delete document.documentElement.dataset.theme
}
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
// Private browsing / blocked storage: the choice just won't persist.
}
}