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