From d9629f212176940a400a966e298420a6adce2482 Mon Sep 17 00:00:00 2001 From: overbalance Date: Wed, 8 Jul 2026 16:00:34 -0500 Subject: [PATCH 1/2] chore(demo): add live span/log waterfall page --- demo/frontend/index.html | 36 -- demo/frontend/package.json | 1 + demo/frontend/src/Layout.tsx | 1 + demo/frontend/src/otel-base.ts | 12 +- demo/frontend/vite.config.ts | 43 +- demo/frontend/waterfall/Waterfall.tsx | 423 ++++++++++++++++++++ demo/frontend/waterfall/index.html | 12 + demo/frontend/waterfall/index.tsx | 109 +++++ demo/frontend/waterfall/telemetryCapture.ts | 114 ++++++ package-lock.json | 1 + 10 files changed, 712 insertions(+), 40 deletions(-) create mode 100644 demo/frontend/waterfall/Waterfall.tsx create mode 100644 demo/frontend/waterfall/index.html create mode 100644 demo/frontend/waterfall/index.tsx create mode 100644 demo/frontend/waterfall/telemetryCapture.ts diff --git a/demo/frontend/index.html b/demo/frontend/index.html index 04d5d8a03..5d9d8d5c9 100644 --- a/demo/frontend/index.html +++ b/demo/frontend/index.html @@ -6,42 +6,6 @@ Embrace Web SDK React Demo -
diff --git a/demo/frontend/package.json b/demo/frontend/package.json index de42f9743..15b77c1d5 100644 --- a/demo/frontend/package.json +++ b/demo/frontend/package.json @@ -17,6 +17,7 @@ "@embrace-io/web-sdk": "*", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/core": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-trace-web": "^2.9.0", "history": "^4.10.1", diff --git a/demo/frontend/src/Layout.tsx b/demo/frontend/src/Layout.tsx index 68b2a53be..307fc61e3 100644 --- a/demo/frontend/src/Layout.tsx +++ b/demo/frontend/src/Layout.tsx @@ -6,6 +6,7 @@ const base = import.meta.env.BASE_URL; const NAV_ITEMS = [ { href: base, label: 'Playground' }, + { href: `${base}waterfall/`, label: 'Waterfall' }, { href: `${base}soft/`, label: 'Soft Nav' }, { href: `${base}react-router-v5/`, label: 'Router v5' }, { href: `${base}react-router-v6-declarative/`, label: 'Router v6' }, diff --git a/demo/frontend/src/otel-base.ts b/demo/frontend/src/otel-base.ts index 36f7c76fc..914dc1003 100644 --- a/demo/frontend/src/otel-base.ts +++ b/demo/frontend/src/otel-base.ts @@ -1,7 +1,9 @@ /// import { DiagLogLevel, initSDK, user } from '@embrace-io/web-sdk'; import type { Instrumentation } from '@opentelemetry/instrumentation'; +import type { LogRecordExporter } from '@opentelemetry/sdk-logs'; import { ConsoleLogRecordExporter } from '@opentelemetry/sdk-logs'; +import type { SpanExporter } from '@opentelemetry/sdk-trace-web'; import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-web'; import { version } from '../../../packages/web-sdk/package.json' with { type: 'json', @@ -11,13 +13,17 @@ const APP_ID = import.meta.env.VITE_APP_ID; const DATA_URL = import.meta.env.VITE_DATA_URL; const CONFIG_URL = import.meta.env.VITE_CONFIG_URL; -const setupSDK = (instrumentations?: Instrumentation[]) => { +const setupSDK = ( + instrumentations?: Instrumentation[], + extraSpanExporters: SpanExporter[] = [], + extraLogExporters: LogRecordExporter[] = [], +) => { const result = initSDK({ logLevel: DiagLogLevel.ALL, appID: APP_ID || undefined, appVersion: version, - spanExporters: [new ConsoleSpanExporter()], - logExporters: [new ConsoleLogRecordExporter()], + spanExporters: [new ConsoleSpanExporter(), ...extraSpanExporters], + logExporters: [new ConsoleLogRecordExporter(), ...extraLogExporters], defaultInstrumentationConfig: { 'empty-root': { rootNode: document.getElementById('root'), diff --git a/demo/frontend/vite.config.ts b/demo/frontend/vite.config.ts index 6b8eeb268..ed9d51280 100644 --- a/demo/frontend/vite.config.ts +++ b/demo/frontend/vite.config.ts @@ -1,13 +1,53 @@ import { resolve } from 'node:path'; import Sonda from 'sonda/vite'; +import type { PluginOption } from 'vite'; import { defineConfig } from 'vite'; +// Sub-app entries: each is a directory with its own index.html. Their in-app +// routes (e.g. /waterfall/a) are client-side pushState paths with no file on +// disk, so a hard load of one needs to be served that entry's index.html. +const SUB_APPS = [ + 'react-router-v5', + 'react-router-v6-declarative', + 'react-router-v6-data', + 'soft', + 'waterfall', +]; + +// History fallback: rewrite an extension-less deep path under a sub-app to that +// sub-app's index.html so hard navigations resolve to the right entry (and keep +// their sub-route in the URL). Applied to both the dev and preview servers. +const rewriteDeepLink = ( + req: { url?: string }, + _res: unknown, + next: () => void, +): void => { + const path = (req.url ?? '').split('?')[0]; + const app = SUB_APPS.find( + (name) => path.startsWith(`/${name}/`) && path !== `/${name}/`, + ); + if (app && !path.slice(1).includes('.')) { + req.url = `/${app}/`; + } + next(); +}; + +const subAppDeepLinkFallback = (): PluginOption => ({ + name: 'sub-app-deep-link-fallback', + configureServer(server) { + server.middlewares.use(rewriteDeepLink); + }, + configurePreviewServer(server) { + server.middlewares.use(rewriteDeepLink); + }, +}); + // Debug collector runs at http://localhost:3001 (started by turbo `with` task). // To send telemetry to it, set VITE_DATA_URL=http://localhost:3001 in .env. // https://vite.dev/config/ export default defineConfig({ base: process.env.GITHUB_ACTIONS ? '/embrace-web-sdk/' : '/', - plugins: [Sonda({ enabled: false })], + plugins: [Sonda({ enabled: false }), subAppDeepLinkFallback()], server: { headers: { 'Server-Timing': 'db;dur=78,cache;dur=0;desc="HIT",render;dur=163', @@ -28,6 +68,7 @@ export default defineConfig({ 'react-router-v6-data/index.html', ), soft: resolve(__dirname, 'soft/index.html'), + waterfall: resolve(__dirname, 'waterfall/index.html'), }, output: { sourcemapDebugIds: true, diff --git a/demo/frontend/waterfall/Waterfall.tsx b/demo/frontend/waterfall/Waterfall.tsx new file mode 100644 index 000000000..cdaaed2ba --- /dev/null +++ b/demo/frontend/waterfall/Waterfall.tsx @@ -0,0 +1,423 @@ +/** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: spans are interactive */ +/** biome-ignore-all lint/a11y/noStaticElementInteractions: spans are interactive */ +/** biome-ignore-all lint/a11y/useKeyWithClickEvents: demo-only debug view, mouse-driven */ +import { useEffect, useRef, useState } from 'react'; +import type { CapturedLog, CapturedSpan } from './telemetryCapture.ts'; +import { + clearCapture, + getCapturedLogs, + getCapturedSpans, + subscribe, +} from './telemetryCapture.ts'; + +const ROW_HEIGHT = 26; +const LOG_LANE_HEIGHT = 28; +// When zoomed, the viewport shows this many milliseconds; the rest of the +// timeline overflows horizontally and is reached by scrolling. +const ZOOM_WINDOW_MS = 1500; + +const SEVERITY_COLOR: Record = { + error: '#f87171', + fatal: '#f87171', + warning: '#fbbf24', + warn: '#fbbf24', + info: '#60a5fa', +}; + +const logColor = (severityText: string): string => + SEVERITY_COLOR[severityText.toLowerCase()] ?? '#a1a1aa'; + +// Brand green (the Embrace accent). Other span types get hues rotated from it +// by the golden angle so each type is distinct but stays in the same family. +const SEED_HEX = '#eeff04'; +const SEED_HUE = 64; // HSL hue of #eeff04 +const GOLDEN_ANGLE = 137.508; + +const hslToHex = (h: number, s: number, l: number): string => { + const sN = s / 100; + const lN = l / 100; + const a = sN * Math.min(lN, 1 - lN); + const channel = (n: number): string => { + const k = (n + h / 30) % 12; + const value = lN - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + return Math.round(255 * value) + .toString(16) + .padStart(2, '0'); + }; + return `#${channel(0)}${channel(8)}${channel(4)}`; +}; + +const hashString = (value: string): number => { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +}; + +// Color by span type (emb.type) so all spans of a kind share a color: every +// React user-timing span one color, every resource fetch another, etc. Session +// part spans get the brand green. Untyped spans (e.g. soft nav) fall back to +// their name so they still get a stable color. +const spanColor = (span: CapturedSpan): string => { + const embType = span.attributes['emb.type']; + if (embType === 'ux.session_part') { + return SEED_HEX; + } + const typeKey = typeof embType === 'string' ? embType : span.name; + const hue = (SEED_HUE + (hashString(typeKey) + 1) * GOLDEN_ANGLE) % 360; + return hslToHex(hue, 70, 62); +}; + +// Dark text on light fills, light text on dark ones, by perceived luminance. +const textOn = (hex: string): string => { + const r = Number.parseInt(hex.slice(1, 3), 16) / 255; + const g = Number.parseInt(hex.slice(3, 5), 16) / 255; + const b = Number.parseInt(hex.slice(5, 7), 16) / 255; + return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.6 ? '#09090b' : '#e4e4e7'; +}; + +// document-load emits these on the initial hard load only (one resourceFetch per +// subresource). They never recur on a soft navigation, so hiding them by default +// keeps the soft-nav picture readable. +const NETWORK_SPAN_NAMES = new Set([ + 'resourcefetch', + 'documentfetch', + 'documentload', +]); + +const isNetworkSpan = (name: string): boolean => { + const lower = name.toLowerCase(); + return lower.startsWith('http ') || NETWORK_SPAN_NAMES.has(lower); +}; + +// Positions everything against a shared [minMs, maxMs] domain so spans and logs +// line up in time. Returns null when there is nothing to draw. Folds min/max in +// a loop rather than spreading into Math.min/max, which throws on large inputs +// (the capture arrays grow unbounded until Clear). +const useDomain = (spans: CapturedSpan[], logs: CapturedLog[]) => { + if (spans.length === 0 && logs.length === 0) { + return null; + } + let minMs = Number.POSITIVE_INFINITY; + let maxMs = Number.NEGATIVE_INFINITY; + const track = (value: number) => { + if (value < minMs) minMs = value; + if (value > maxMs) maxMs = value; + }; + for (const span of spans) { + track(span.startMs); + track(span.endMs); + } + for (const log of logs) { + track(log.timeMs); + } + const rangeMs = Math.max(maxMs - minMs, 1); + return { minMs, rangeMs }; +}; + +const pct = (value: number): string => `${value * 100}%`; + +interface Tip { + title: string; + lines: string[]; + x: number; + y: number; +} + +const Waterfall = () => { + const [, forceRender] = useState(0); + const [hideNetwork, setHideNetwork] = useState(true); + const [tip, setTip] = useState(null); + // When zoomed, the timeline is rendered wider than the viewport at a fixed + // scale (ZOOM_WINDOW_MS across the viewport) and scrolls horizontally. + const [zoomed, setZoomed] = useState(false); + const scrollRef = useRef(null); + const scrollToMsRef = useRef(null); + + useEffect(() => subscribe(() => forceRender((n) => n + 1)), []); + + const allSpans = [...getCapturedSpans()].sort( + (a, b) => a.startMs - b.startMs, + ); + const spans = hideNetwork + ? allSpans.filter((span) => !isNetworkSpan(span.name)) + : allSpans; + const logs = [...getCapturedLogs()]; + const hiddenCount = allSpans.length - spans.length; + // Positions are always computed against the full extent; zoom only stretches + // the inner content width and lets it scroll. + const domain = useDomain(spans, logs); + const innerWidthPct = + zoomed && domain + ? Math.max((domain.rangeMs / ZOOM_WINDOW_MS) * 100, 100) + : 100; + + const ticks: number[] = []; + if (domain) { + // Aim for ~5 ticks across the zoomed viewport; coarser when showing it all. + // Floor the step so a long timeline can't produce thousands of labels. + const baseStep = zoomed + ? ZOOM_WINDOW_MS / 5 + : Math.max(Math.round(domain.rangeMs / 6), 1); + const tickStepMs = Math.max(baseStep, domain.rangeMs / 150); + for (let t = 0; t <= domain.rangeMs; t += tickStepMs) { + ticks.push(t); + } + } + + // After a zoom-to-span, scroll so the requested time is centered. + useEffect(() => { + const target = scrollToMsRef.current; + scrollToMsRef.current = null; + const el = scrollRef.current; + if (target == null || el == null || domain == null) { + return; + } + const fraction = (target - domain.minMs) / domain.rangeMs; + el.scrollLeft = fraction * el.scrollWidth - el.clientWidth / 2; + }); + + const clearTip = () => setTip(null); + // A short span (a soft nav can be ~30 ms) is a hairline at full scale. Clicking + // it turns on the fixed ZOOM_WINDOW_MS scale and centers the view on the span. + const zoomToSpan = (span: CapturedSpan) => { + scrollToMsRef.current = (span.startMs + span.endMs) / 2; + setZoomed(true); + }; + const resetZoom = () => setZoomed(false); + const handleClear = () => { + setZoomed(false); + clearCapture(); + }; + const showSpanTip = ( + span: CapturedSpan, + e: { clientX: number; clientY: number }, + ) => { + setTip({ + title: span.name, + lines: [ + `${(span.endMs - span.startMs).toFixed(1)} ms`, + domain ? `start +${(span.startMs - domain.minMs).toFixed(0)} ms` : '', + `spanId ${span.spanId}`, + 'click to zoom', + ].filter(Boolean), + x: e.clientX, + y: e.clientY, + }); + }; + const showLogTip = ( + log: CapturedLog, + e: { clientX: number; clientY: number }, + ) => { + setTip({ + title: log.eventName ?? log.embType ?? log.severityText, + lines: [ + log.body || '(empty body)', + `severity ${log.severityText}`, + domain ? `+${(log.timeMs - domain.minMs).toFixed(0)} ms` : '', + ].filter(Boolean), + x: e.clientX, + y: e.clientY, + }); + }; + + return ( +
+ Waterfall +
+ + {zoomed ? ( + + ) : null} + + + {spans.length} spans + {hiddenCount > 0 ? ` (+${hiddenCount} hidden)` : ''} ยท {logs.length}{' '} + logs + +
+ + {domain === null ? ( +

+ Nothing captured yet. Navigate between pages and emit logs; spans + flush when a session part ends (on each soft navigation). +

+ ) : ( +
+
+ {zoomed ? ( +
+ {ZOOM_WINDOW_MS / 1000}s window, scroll horizontally to pan +
+ ) : null} + + {/* Span rows */} +
+ {/* Vertical guide line per log, spanning the span rows */} + {logs.map((log) => ( +
showLogTip(log, e)} + onMouseLeave={clearTip} + style={{ + position: 'absolute', + top: 0, + bottom: 0, + left: pct((log.timeMs - domain.minMs) / domain.rangeMs), + width: 1, + background: logColor(log.severityText), + opacity: 0.35, + }} + /> + ))} + {spans.map((span, i) => { + const left = (span.startMs - domain.minMs) / domain.rangeMs; + const width = (span.endMs - span.startMs) / domain.rangeMs; + const background = spanColor(span); + return ( +
showSpanTip(span, e)} + onMouseLeave={clearTip} + onClick={() => zoomToSpan(span)} + style={{ + position: 'absolute', + top: i * ROW_HEIGHT, + height: ROW_HEIGHT - 4, + left: pct(left), + width: pct(width), + minWidth: 3, + cursor: 'pointer', + background, + color: textOn(background), + borderRadius: 3, + fontSize: '0.72rem', + lineHeight: `${ROW_HEIGHT - 4}px`, + paddingLeft: 4, + whiteSpace: 'nowrap', + overflow: 'hidden', + boxSizing: 'border-box', + }} + > + {span.name} +
+ ); + })} +
+ + {/* Log lane */} +
+ {logs.map((log) => ( +
showLogTip(log, e)} + onMouseLeave={clearTip} + style={{ + position: 'absolute', + top: LOG_LANE_HEIGHT / 2 - 6, + left: pct((log.timeMs - domain.minMs) / domain.rangeMs), + width: 12, + height: 12, + marginLeft: -6, + borderRadius: '50%', + background: logColor(log.severityText), + cursor: 'pointer', + }} + /> + ))} +
+ + {/* Time axis */} +
+ {ticks.map((t) => ( + + {t.toFixed(0)} ms + + ))} +
+
+
+ )} + + {tip ? ( +
+
+ {tip.title} +
+ {tip.lines.map((line) => ( +
+ {line} +
+ ))} +
+ ) : null} +
+ ); +}; + +export { Waterfall }; diff --git a/demo/frontend/waterfall/index.html b/demo/frontend/waterfall/index.html new file mode 100644 index 000000000..33a7ac912 --- /dev/null +++ b/demo/frontend/waterfall/index.html @@ -0,0 +1,12 @@ + + + + + + Waterfall - Embrace Web SDK + + +
+ + + diff --git a/demo/frontend/waterfall/index.tsx b/demo/frontend/waterfall/index.tsx new file mode 100644 index 000000000..4199086af --- /dev/null +++ b/demo/frontend/waterfall/index.tsx @@ -0,0 +1,109 @@ +/// +import { StrictMode, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import '../src/index.css'; +import { log } from '@embrace-io/web-sdk'; +import { Layout } from '../src/Layout.tsx'; +import { NavPage } from '../src/NavPage.tsx'; +import { setupSDK } from '../src/otel-base.ts'; +import { + CapturingLogExporter, + CapturingSpanExporter, +} from './telemetryCapture.ts'; +import { Waterfall } from './Waterfall.tsx'; + +setupSDK([], [new CapturingSpanExporter()], [new CapturingLogExporter()]); + +const emitLog = (severity: 'info' | 'warning' | 'error') => { + log.getLogManager().message(`Waterfall demo ${severity} log`, severity, { + attributes: { source: 'waterfall-demo' }, + }); +}; + +type Route = 'home' | 'a' | 'b'; + +const PAGES: Record< + Route, + { path: string; title: string; description: string } +> = { + home: { + path: 'waterfall/', + title: 'Home', + description: + 'Click a nav button to soft-navigate (history.pushState), or emit a log, and watch both appear on the waterfall below. Click a span to zoom in.', + }, + a: { + path: 'waterfall/a', + title: 'Page A', + description: + 'You soft-navigated to Page A. The URL changed without a full page reload, which rolls the session part.', + }, + b: { + path: 'waterfall/b', + title: 'Page B', + description: + 'You soft-navigated to Page B. Emit some logs, then click the soft-nav span to see the overlap.', + }, +}; + +const getRoute = (): Route => { + const path = window.location.pathname; + if (path.endsWith('/a')) return 'a'; + if (path.endsWith('/b')) return 'b'; + return 'home'; +}; + +const base = import.meta.env.BASE_URL; + +const App = () => { + const [route, setRoute] = useState(getRoute); + + const navigate = (target: Route) => { + history.pushState(null, '', `${base}${PAGES[target].path}`); + setRoute(target); + }; + + useEffect(() => { + const onPopState = () => setRoute(getRoute()); + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + + const page = PAGES[route]; + const nav = (Object.keys(PAGES) as Route[]).map((r) => ( + + )); + + return ( + <> + +

{page.description}

+
+ + + +
+
+ + + ); +}; + +const rootElement = document.getElementById('root'); +if (rootElement) { + createRoot(rootElement).render( + + + + + , + ); +} diff --git a/demo/frontend/waterfall/telemetryCapture.ts b/demo/frontend/waterfall/telemetryCapture.ts new file mode 100644 index 000000000..0523254b3 --- /dev/null +++ b/demo/frontend/waterfall/telemetryCapture.ts @@ -0,0 +1,114 @@ +import type { Attributes } from '@opentelemetry/api'; +import type { ExportResult } from '@opentelemetry/core'; +import { ExportResultCode, hrTimeToMilliseconds } from '@opentelemetry/core'; +import type { + LogRecordExporter, + ReadableLogRecord, +} from '@opentelemetry/sdk-logs'; +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-web'; + +// Demo-only visualization aid: taps the SDK's exporter chain so the waterfall +// page can draw a live waterfall of spans and logs. Not shipped in the SDK. + +export interface CapturedSpan { + spanId: string; + traceId: string; + name: string; + startMs: number; + endMs: number; + attributes: Attributes; +} + +export interface CapturedLog { + uid: string; + timeMs: number; + eventName: string | null; + embType: string | null; + severityText: string; + body: string; + spanId: string | null; +} + +const capturedSpans: CapturedSpan[] = []; +const capturedLogs: CapturedLog[] = []; +const listeners = new Set<() => void>(); + +const notify = () => { + for (const listener of listeners) { + listener(); + } +}; + +export const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const getCapturedSpans = (): readonly CapturedSpan[] => capturedSpans; +export const getCapturedLogs = (): readonly CapturedLog[] => capturedLogs; + +export const clearCapture = (): void => { + capturedSpans.length = 0; + capturedLogs.length = 0; + notify(); +}; + +export class CapturingSpanExporter implements SpanExporter { + public export( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void, + ): void { + for (const span of spans) { + capturedSpans.push({ + spanId: span.spanContext().spanId, + traceId: span.spanContext().traceId, + name: span.name, + startMs: hrTimeToMilliseconds(span.startTime), + endMs: hrTimeToMilliseconds(span.endTime), + attributes: { ...span.attributes }, + }); + } + // Capture already succeeded; report success before notifying subscribers so + // a throwing listener can't leave the export promise unsettled. + resultCallback({ code: ExportResultCode.SUCCESS }); + notify(); + } + + public shutdown(): Promise { + return Promise.resolve(); + } +} + +export class CapturingLogExporter implements LogRecordExporter { + public forceFlush(): Promise { + return Promise.resolve(); + } + + public export( + logs: ReadableLogRecord[], + resultCallback: (result: ExportResult) => void, + ): void { + for (const record of logs) { + const uid = record.attributes['log.record.uid']; + const embType = record.attributes['emb.type']; + capturedLogs.push({ + uid: typeof uid === 'string' ? uid : '', + timeMs: hrTimeToMilliseconds(record.hrTime), + eventName: record.eventName ?? null, + embType: typeof embType === 'string' ? embType : null, + severityText: record.severityText ?? 'unspecified', + body: record.body == null ? '' : String(record.body), + // biome-ignore lint/suspicious/noUnnecessaryConditions: a log emitted with no active span has an undefined spanContext at runtime, despite the non-optional type + spanId: record.spanContext?.spanId ?? null, + }); + } + resultCallback({ code: ExportResultCode.SUCCESS }); + notify(); + } + + public shutdown(): Promise { + return Promise.resolve(); + } +} diff --git a/package-lock.json b/package-lock.json index bbbc174c5..2e19f658e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "@embrace-io/web-sdk": "*", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/core": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-trace-web": "^2.9.0", "history": "^4.10.1", From e8ded943a202827122e15c605d08a7ed011bc709 Mon Sep 17 00:00:00 2001 From: overbalance Date: Wed, 8 Jul 2026 16:18:37 -0500 Subject: [PATCH 2/2] chore(demo): improve waterfall tooltips (end time, edge-safe position, italic hint) --- demo/frontend/waterfall/Waterfall.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/demo/frontend/waterfall/Waterfall.tsx b/demo/frontend/waterfall/Waterfall.tsx index cdaaed2ba..19eab2017 100644 --- a/demo/frontend/waterfall/Waterfall.tsx +++ b/demo/frontend/waterfall/Waterfall.tsx @@ -199,6 +199,7 @@ const Waterfall = () => { lines: [ `${(span.endMs - span.startMs).toFixed(1)} ms`, domain ? `start +${(span.startMs - domain.minMs).toFixed(0)} ms` : '', + domain ? `end +${(span.endMs - domain.minMs).toFixed(0)} ms` : '', `spanId ${span.spanId}`, 'click to zoom', ].filter(Boolean), @@ -222,6 +223,11 @@ const Waterfall = () => { }); }; + // Near the right/bottom third of the viewport, open the tooltip toward the + // opposite side of the cursor so it doesn't run off the edge. + const tipOpensLeft = tip !== null && tip.x > window.innerWidth * (2 / 3); + const tipOpensUp = tip !== null && tip.y > window.innerHeight * (2 / 3); + return (
Waterfall @@ -389,8 +395,10 @@ const Waterfall = () => {
{ {tip.lines.map((line) => (
{line}