mreo ui
This commit is contained in:
1 parent
49f9bb3458
commit
7436966689
22 files changed
+989
-125
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import { getAttributeValue } from "./otel-utils"
|
||||
import type { TraceData } from "./types"
|
||||
|
||||
/**
|
||||
* Normalizes the many attribute spellings for "which HTTP endpoint" (stable
|
||||
* semconv, legacy semconv, nginx, Go, browser RUM) into one shape. Mirrors
|
||||
* internal/httpattr in the Go backend - keep the two in sync.
|
||||
*/
|
||||
|
||||
export interface HttpEndpoint {
|
||||
method: string
|
||||
host: string
|
||||
path: string
|
||||
route: string
|
||||
}
|
||||
|
||||
type Get = (...keys: string[]) => string
|
||||
|
||||
export function cleanPath(p: string): string {
|
||||
p = p.split(/[?#]/)[0].trim()
|
||||
if (!p) return ""
|
||||
if (!p.startsWith("/")) p = "/" + p
|
||||
return p.length > 1 ? p.replace(/\/+$/, "") || "/" : p
|
||||
}
|
||||
|
||||
function stripPort(host: string): string {
|
||||
return host.replace(/:\d+$/, "")
|
||||
}
|
||||
|
||||
export function splitUrl(raw: string): { host: string; path: string } {
|
||||
try {
|
||||
const u = new URL(raw, "http://relative.invalid")
|
||||
const host = u.host === "relative.invalid" ? "" : stripPort(u.host.toLowerCase())
|
||||
return { host, path: cleanPath(u.pathname) }
|
||||
} catch {
|
||||
return { host: "", path: cleanPath(raw) }
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEndpoint(get: Get): HttpEndpoint {
|
||||
const e: HttpEndpoint = {
|
||||
method: get("http.request.method", "http.method").toUpperCase(),
|
||||
route: get("http.route"),
|
||||
host: stripPort(get("server.address", "http.host", "net.host.name", "server.host").toLowerCase()),
|
||||
path: cleanPath(get("url.path") || get("http.target")),
|
||||
}
|
||||
const full = get("url.full", "http.url")
|
||||
if (full) {
|
||||
const u = splitUrl(full)
|
||||
e.path ||= u.path
|
||||
e.host ||= u.host
|
||||
}
|
||||
if (e.route === "/" && e.path && e.path !== "/") e.route = ""
|
||||
return e
|
||||
}
|
||||
|
||||
/** Removes a reverse-proxy mount prefix; longest match wins. */
|
||||
export function stripPrefix(path: string, prefixes: string[]): string {
|
||||
let best = ""
|
||||
for (const raw of prefixes) {
|
||||
const p = cleanPath(raw)
|
||||
if (p && p !== "/" && (path === p || path.startsWith(p + "/")) && p.length > best.length) best = p
|
||||
}
|
||||
return best ? path.slice(best.length) || "/" : path
|
||||
}
|
||||
|
||||
export interface BackendRequest {
|
||||
traceId: string
|
||||
spanId: string
|
||||
service: string
|
||||
startMs: number
|
||||
durationMs: number
|
||||
endpoint: HttpEndpoint
|
||||
}
|
||||
|
||||
export interface RumRequest {
|
||||
timestamp: number
|
||||
traceId?: string
|
||||
method?: string
|
||||
url?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export type LinkConfidence = "trace-id" | "path+time"
|
||||
|
||||
export interface RumBackendLink {
|
||||
backend: BackendRequest
|
||||
confidence: LinkConfidence
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
/** Proxy mount prefixes the browser sees but the backend does not, e.g. ["/api"]. */
|
||||
stripPrefixes?: string[]
|
||||
/** How far a backend span start may be from the browser request, in ms. */
|
||||
windowMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the backend span a browser request hit. Exact trace id wins; when the
|
||||
* hop in between (e.g. an uninstrumented nginx that neither propagates
|
||||
* traceparent nor emits spans) breaks the trace, fall back to method + path
|
||||
* inside a time window. Returns null unless exactly one candidate is closest,
|
||||
* so an ambiguous match is never presented as a link.
|
||||
*/
|
||||
export function linkRumRequest(
|
||||
req: RumRequest,
|
||||
backend: BackendRequest[],
|
||||
{ stripPrefixes = [], windowMs = 2000 }: LinkOptions = {},
|
||||
): RumBackendLink | null {
|
||||
if (req.traceId) {
|
||||
const hit = backend.find((b) => b.traceId === req.traceId)
|
||||
if (hit) return { backend: hit, confidence: "trace-id" }
|
||||
}
|
||||
if (!req.url) return null
|
||||
const { path } = splitUrl(req.url)
|
||||
const method = (req.method || "GET").toUpperCase()
|
||||
const wanted = new Set([path, stripPrefix(path, stripPrefixes)])
|
||||
// The request can only reach the server after the browser started it, and
|
||||
// must have started before the browser saw the response.
|
||||
const end = req.timestamp + (req.durationMs ?? 0)
|
||||
const candidates = backend
|
||||
.filter((b) => b.endpoint.method === method && wanted.has(b.endpoint.path))
|
||||
.filter((b) => b.startMs >= req.timestamp - windowMs && b.startMs <= Math.max(end, req.timestamp) + windowMs)
|
||||
.map((b) => ({ b, d: Math.abs(b.startMs - req.timestamp) }))
|
||||
.sort((x, y) => x.d - y.d)
|
||||
if (candidates.length === 0) return null
|
||||
if (candidates.length > 1 && candidates[0].d === candidates[1].d) return null
|
||||
return { backend: candidates[0].b, confidence: "path+time" }
|
||||
}
|
||||
|
||||
/** Server-side HTTP spans (kind SERVER) from loaded traces, normalized for linking. */
|
||||
export function backendRequestsFromTraces(traces: TraceData[]): BackendRequest[] {
|
||||
const out: BackendRequest[] = []
|
||||
for (const trace of traces) {
|
||||
for (const rs of trace.resourceSpans ?? []) {
|
||||
const service = getAttributeValue(rs.resource?.attributes ?? [], "service.name")
|
||||
for (const ss of rs.scopeSpans ?? []) {
|
||||
for (const s of ss.spans ?? []) {
|
||||
if (s.kind !== 2) continue
|
||||
const endpoint = extractEndpoint((...keys) => {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(s.attributes ?? [], k)
|
||||
if (v) return v
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if (!endpoint.path) continue
|
||||
const start = Number(BigInt(s.startTimeUnixNano) / 1_000_000n)
|
||||
const end = Number(BigInt(s.endTimeUnixNano) / 1_000_000n)
|
||||
out.push({ traceId: s.traceId, spanId: s.spanId, service, startMs: start, durationMs: end - start, endpoint })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parses an otel-rum.js fetch/xhr breadcrumb: "GET /api/x -> 200". */
|
||||
export function parseRequestCrumb(message: string): { method: string; url: string } | null {
|
||||
const m = /^([A-Z]+)\s+(\S+)\s+->\s+\S+$/.exec(message)
|
||||
return m ? { method: m[1], url: m[2] } : null
|
||||
}
|
||||
@@ -35,6 +35,10 @@ export interface ParsedBreadcrumb {
|
||||
t: number
|
||||
category: string
|
||||
message: string
|
||||
/** Request duration in ms (fetch/xhr crumbs); the request started at t - d. */
|
||||
d?: number
|
||||
/** Trace id otel-rum.js sent on the request, if it injected traceparent. */
|
||||
tid?: string
|
||||
}
|
||||
|
||||
/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface RumTimelineEntry {
|
||||
category: "session-start" | "navigation" | "fetch" | "xhr" | "console.error" | "js-error" | "http-failure"
|
||||
message: string
|
||||
severe: boolean
|
||||
durationMs?: number
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export interface RumSession {
|
||||
@@ -273,6 +275,8 @@ export function buildRumSessions(logs: LogEntry[]): RumSession[] {
|
||||
: "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) {
|
||||
|
||||
Reference in new issue
Block a user