This commit is contained in:
mats committed 2026-09-19 14:13:43 +02:00
1 parent 49f9bb3458
commit 7436966689
22 files changed
+989 -125

No files matched your search

+162
View File
@@ -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
}
+4
View File
@@ -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. */
+4
View File
@@ -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) {