+ {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",