+
+
diff --git a/examples/dogfooding/package-lock.json b/examples/dogfooding/package-lock.json
index 62a6758..76aeec2 100644
--- a/examples/dogfooding/package-lock.json
+++ b/examples/dogfooding/package-lock.json
@@ -11,6 +11,8 @@
"@libp2p/crypto": "^5.0.5",
"@multiformats/multiaddr": "^12.3.1",
"@waku/sdk": "0.0.34",
+ "chart.js": "^4.4.1",
+ "chartjs-plugin-zoom": "^2.0.1",
"libp2p": "^2.1.10",
"protobufjs": "^7.3.0",
"uint8arrays": "^5.1.0"
@@ -331,6 +333,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
@@ -995,6 +1003,12 @@
"@types/send": "*"
}
},
+ "node_modules/@types/hammerjs": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
+ "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
+ "license": "MIT"
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -2749,6 +2763,31 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chart.js": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
+ "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
+ "node_modules/chartjs-plugin-zoom": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
+ "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hammerjs": "^2.0.45",
+ "hammerjs": "^2.0.8"
+ },
+ "peerDependencies": {
+ "chart.js": ">=3.2.0"
+ }
+ },
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@@ -4822,6 +4861,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/hammerjs": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
+ "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/handle-thing": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
diff --git a/examples/dogfooding/package.json b/examples/dogfooding/package.json
index 9d063b4..3455b12 100644
--- a/examples/dogfooding/package.json
+++ b/examples/dogfooding/package.json
@@ -10,6 +10,8 @@
"@libp2p/crypto": "^5.0.5",
"@multiformats/multiaddr": "^12.3.1",
"@waku/sdk": "0.0.34",
+ "chart.js": "^4.4.1",
+ "chartjs-plugin-zoom": "^2.0.1",
"libp2p": "^2.1.10",
"protobufjs": "^7.3.0",
"uint8arrays": "^5.1.0"
diff --git a/examples/dogfooding/public/style.css b/examples/dogfooding/public/style.css
index d10d7e8..2980905 100644
--- a/examples/dogfooding/public/style.css
+++ b/examples/dogfooding/public/style.css
@@ -115,6 +115,43 @@ h2 {
padding: 10px;
}
+.collapsible-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.collapsible.hidden {
+ display: none;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+table th, table td {
+ padding: 8px;
+ border-bottom: 1px solid #ecf0f1;
+ text-align: left;
+ font-size: 0.9em;
+}
+
+.latency-summary {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ font-weight: 600;
+ color: #34495e;
+}
+
+/* Scrollable tables */
+#discoveryTableContainer,
+#connectionsTableContainer,
+#latencyTableContainer {
+ max-height: 260px;
+ overflow-y: auto;
+}
+
.message-item {
padding: 10px;
margin-bottom: 8px;
diff --git a/examples/dogfooding/src/index.ts b/examples/dogfooding/src/index.ts
index f1ed3ad..c267b6c 100644
--- a/examples/dogfooding/src/index.ts
+++ b/examples/dogfooding/src/index.ts
@@ -20,6 +20,13 @@ import {
addMessageToLog,
renderMessages,
getSearchTerm,
+ initCharts,
+ onDiscoveryUpdate,
+ onConnectionsUpdate,
+ wireUiToggles,
+ trackMessageSent,
+ trackMessageReceived,
+ recordLatency,
} from "./ui-manager";
const NUM_MESSAGES_PER_BATCH = 5;
@@ -66,6 +73,7 @@ async function initializeApp() {
console.log(`Message ${i + 1} (ID: ${chatMessage.id}) sent successfully.`);
incrementSentByMe();
addMessageToLog(chatMessage, 'sent');
+ trackMessageSent(chatMessage.id, chatMessage.timestamp);
} else {
console.warn(`Failed to send message ${i + 1} (ID: ${chatMessage.id}):`, result.failures);
const failureReason = result.failures.length > 0
@@ -133,6 +141,7 @@ async function initializeApp() {
console.log(`Continuous message (ID: ${chatMessage.id}) sent successfully.`);
incrementSentByMe();
addMessageToLog(chatMessage, 'sent');
+ trackMessageSent(chatMessage.id, chatMessage.timestamp);
} else {
console.warn(`Failed to send continuous message (ID: ${chatMessage.id}):`, result.failures);
}
@@ -184,6 +193,12 @@ async function initializeApp() {
addMessageToLog(chatMessage, 'received-other');
console.log("Received message from other peer:", chatMessage.id);
}
+ // Use encoded timestamp when available for more accurate latency
+ if (chatMessage.timestamp) {
+ recordLatency(chatMessage.id, chatMessage.timestamp, Date.now());
+ } else {
+ trackMessageReceived(chatMessage.id, Date.now());
+ }
} else {
console.warn("Could not decode received Waku message. Payload might be malformed or not a ChatMessage.");
}
@@ -226,6 +241,10 @@ async function initializeApp() {
});
}
+ initCharts();
+ (window as any).onDiscoveryUpdate = onDiscoveryUpdate;
+ (window as any).onConnectionsUpdate = onConnectionsUpdate;
+ wireUiToggles();
await subscribeToMessages();
console.log("Application setup complete. Click 'Send New Message Batch' to send messages.");
diff --git a/examples/dogfooding/src/ui-manager.ts b/examples/dogfooding/src/ui-manager.ts
index b545628..04743c8 100644
--- a/examples/dogfooding/src/ui-manager.ts
+++ b/examples/dogfooding/src/ui-manager.ts
@@ -1,4 +1,9 @@
import { ChatMessage } from "./message-service";
+import { getNodeCreationTime } from "./waku-service";
+import Chart from "chart.js/auto";
+// @ts-ignore - plugin has no types in our env
+import zoomPlugin from "chartjs-plugin-zoom";
+Chart.register(zoomPlugin as any);
const sentByMeCountEl = document.getElementById("sentByMeCount") as HTMLSpanElement;
const receivedMineCountEl = document.getElementById("receivedMineCount") as HTMLSpanElement;
@@ -13,9 +18,24 @@ let receivedMine = 0;
let receivedOthers = 0;
let failedToSend = 0;
-let currentMessages: ChatMessage[] = [];
+const currentMessages: ChatMessage[] = [];
let currentPeerId: string | undefined;
+const discoveryChartEl = document.getElementById("discoveryChart") as HTMLCanvasElement | null;
+const connectionsChartEl = document.getElementById("connectionsChart") as HTMLCanvasElement | null;
+const latencyChartEl = document.getElementById("latencyChart") as HTMLCanvasElement | null;
+
+let discoveryChart: Chart | null = null;
+let connectionsChart: Chart | null = null;
+let latencyChart: Chart | null = null;
+
+const discoveryTableBody = document.querySelector("#discoveryTable tbody") as HTMLTableSectionElement | null;
+const connectionsTableBody = document.querySelector("#connectionsTable tbody") as HTMLTableSectionElement | null;
+const latencyTableBody = document.querySelector("#latencyTable tbody") as HTMLTableSectionElement | null;
+const latencySummaryTopEl = document.getElementById("latencySummaryTop") as HTMLDivElement | null;
+const ttfcSummaryEl = document.getElementById("ttfcSummary") as HTMLDivElement | null;
+const discoverySummaryTopEl = document.getElementById("discoverySummaryTop") as HTMLDivElement | null;
+
export function updatePeerIdDisplay(peerId: string) {
currentPeerId = peerId;
if (peerIdDisplayEl) {
@@ -126,3 +146,179 @@ export function renderMessages(filterText?: string) {
export function getSearchTerm(): string {
return searchInputEl ? searchInputEl.value : "";
}
+
+function toRelTime(ms: number) {
+ const start = getNodeCreationTime();
+ return (ms - start) / 1000;
+}
+
+export function setupCollapsibles() {
+ const pairs: Array<[string, string]> = [
+ ["toggleLog", "messageLogContainer"],
+ ["toggleDiscoveryTable", "discoveryTableContainer"],
+ ["toggleConnectionsTable", "connectionsTableContainer"],
+ ["toggleLatencyTable", "latencyTableContainer"],
+ ];
+ pairs.forEach(([headerId, containerId]) => {
+ const header = document.getElementById(headerId);
+ const container = document.getElementById(containerId);
+ const btn = header?.querySelector("button");
+ if (!header || !container || !btn) return;
+ btn.addEventListener("click", () => {
+ const hidden = container.classList.toggle("hidden");
+ btn.textContent = hidden ? "Show" : "Hide";
+ });
+ });
+}
+
+export function initCharts() {
+ if (discoveryChartEl) {
+ discoveryChart = new Chart(discoveryChartEl, {
+ type: "line",
+ data: {
+ datasets: [
+ { label: "bootstrap", data: [], borderColor: "#1abc9c", tension: 0.2, pointRadius: 2, borderWidth: 2 },
+ { label: "peer-exchange", data: [], borderColor: "#e67e22", tension: 0.2, pointRadius: 2, borderWidth: 2 },
+ { label: "peer-cache", data: [], borderColor: "#9b59b6", tension: 0.2, pointRadius: 2, borderWidth: 2 },
+ ],
+ },
+ options: { scales: { x: { type: 'linear', title: { display: true, text: "time (s)" } }, y: { title: { display: true, text: "peers" } } } },
+ });
+ }
+ if (connectionsChartEl) {
+ connectionsChart = new Chart(connectionsChartEl, {
+ type: "line",
+ data: { datasets: [{ label: "connections", data: [], borderColor: "#2980b9", tension: 0.25, pointRadius: 2, borderWidth: 2 }] },
+ options: {
+ scales: { x: { type: 'linear', title: { display: true, text: "time (s)" } }, y: { title: { display: true, text: "peers" } } },
+ plugins: {
+ zoom: {
+ zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' },
+ pan: { enabled: true, mode: 'x' }
+ }
+ }
+ },
+ });
+ }
+ if (latencyChartEl) {
+ latencyChart = new Chart(latencyChartEl, {
+ type: "bar",
+ data: { labels: [], datasets: [{ label: "latency (ms)", data: [], backgroundColor: "#34495e" }] },
+ options: { scales: { x: { type: 'category', title: { display: true, text: "message" } }, y: { title: { display: true, text: "ms" } } } },
+ });
+ }
+}
+
+export function onDiscoveryUpdate(timeline: Array<{ time: number; type: string; total: number }>) {
+ if (!discoveryChart || !discoveryTableBody) return;
+ const grouped: Record
> = {};
+ discoveryChart.data.datasets?.forEach(ds => { grouped[ds.label as string] = []; });
+ timeline.forEach(ev => {
+ if (!grouped[ev.type]) grouped[ev.type] = [];
+ grouped[ev.type].push({ x: toRelTime(ev.time), y: ev.total });
+ });
+ discoveryChart.data.datasets?.forEach(ds => {
+ const label = ds.label as string;
+ // @ts-ignore
+ ds.data = grouped[label] || [];
+ });
+ discoveryChart.update();
+
+ if (discoverySummaryTopEl) {
+ const firsts: Record = {
+ "bootstrap": undefined,
+ "peer-exchange": undefined,
+ "peer-cache": undefined,
+ };
+ for (const ev of timeline) {
+ if (firsts[ev.type] === undefined) firsts[ev.type] = ev.time;
+ }
+ const fmt = (t?: number) => t !== undefined ? `${toRelTime(t).toFixed(2)}s` : "-";
+ discoverySummaryTopEl.textContent = `first bootstrap: ${fmt(firsts["bootstrap"])}, peer-exchange: ${fmt(firsts["peer-exchange"])}, peer-cache: ${fmt(firsts["peer-cache"])}`;
+ }
+
+ discoveryTableBody.innerHTML = "";
+ timeline.slice(-100).forEach(ev => {
+ const tr = document.createElement("tr");
+ const tdTime = document.createElement("td");
+ tdTime.textContent = new Date(ev.time).toLocaleTimeString();
+ const tdType = document.createElement("td");
+ tdType.textContent = ev.type;
+ const tdTotal = document.createElement("td");
+ tdTotal.textContent = String(ev.total);
+ tr.appendChild(tdTime); tr.appendChild(tdType); tr.appendChild(tdTotal);
+ discoveryTableBody.appendChild(tr);
+ });
+}
+
+export function onConnectionsUpdate(timeline: Array<{ time: number; total: number }>) {
+ if (!connectionsChart || !connectionsTableBody) return;
+ const data = timeline.map(ev => ({ x: toRelTime(ev.time), y: ev.total }));
+ // @ts-ignore
+ connectionsChart.data.datasets[0].data = data;
+ connectionsChart.update();
+
+ if (ttfcSummaryEl && timeline.length > 0) {
+ const first = timeline[0];
+ const seconds = toRelTime(first.time);
+ ttfcSummaryEl.textContent = `time to first connection: ${seconds.toFixed(2)}s`;
+ }
+
+ connectionsTableBody.innerHTML = "";
+ timeline.slice(-100).forEach(ev => {
+ const tr = document.createElement("tr");
+ const tdTime = document.createElement("td");
+ tdTime.textContent = new Date(ev.time).toLocaleTimeString();
+ const tdTotal = document.createElement("td");
+ tdTotal.textContent = String(ev.total);
+ tr.appendChild(tdTime); tr.appendChild(tdTotal);
+ connectionsTableBody.appendChild(tr);
+ });
+}
+
+const sentTimestamps = new Map();
+
+export function trackMessageSent(id: string, when: number) {
+ sentTimestamps.set(id, when);
+}
+
+export function trackMessageReceived(id: string, when: number) {
+ const sent = sentTimestamps.get(id);
+ if (sent === undefined) return;
+ const latency = Math.max(0, when - sent);
+ if (latencyChart && latencyTableBody) {
+ // push label + numeric value for bar chart
+ const label = id.slice(-6);
+ latencyChart.data.labels?.push(label);
+ // @ts-ignore
+ latencyChart.data.datasets[0].data.push(latency);
+ latencyChart.update();
+
+ const tr = document.createElement("tr");
+ const tdId = document.createElement("td"); tdId.textContent = id;
+ const tdSent = document.createElement("td"); tdSent.textContent = new Date(sent).toLocaleTimeString();
+ const tdRecv = document.createElement("td"); tdRecv.textContent = new Date(when).toLocaleTimeString();
+ const tdLat = document.createElement("td"); tdLat.textContent = String(latency);
+ tr.appendChild(tdId); tr.appendChild(tdSent); tr.appendChild(tdRecv); tr.appendChild(tdLat);
+ latencyTableBody.appendChild(tr);
+
+ const values: number[] = (latencyChart.data.datasets[0].data as any[]).map((v: any) => Number(v));
+ if (latencySummaryTopEl) {
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
+ const sorted = [...values].sort((a, b) => a - b);
+ const p = (q: number) => sorted[Math.floor((q / 100) * (sorted.length - 1))] ?? 0;
+ latencySummaryTopEl.textContent = `avg=${avg.toFixed(1)}ms p90=${p(90)}ms p95=${p(95)}ms p99=${p(99)}ms`;
+ }
+ }
+}
+
+// In case a message is received before we recorded its send time (tab reload, etc.)
+export function recordLatency(id: string, sent: number, received?: number) {
+ const when = received ?? Date.now();
+ sentTimestamps.set(id, sent);
+ trackMessageReceived(id, when);
+}
+
+export function wireUiToggles() {
+ setupCollapsibles();
+}
diff --git a/examples/dogfooding/src/waku-service.ts b/examples/dogfooding/src/waku-service.ts
index 2d8d23a..5dd1dfe 100644
--- a/examples/dogfooding/src/waku-service.ts
+++ b/examples/dogfooding/src/waku-service.ts
@@ -6,6 +6,35 @@ import { sha256, generateRandomNumber } from "./utils";
export const DEFAULT_CONTENT_TOPIC = "/js-waku-examples/1/message-ratio/utf8";
let wakuNodeInstance: LightNode | null = null;
+let nodeCreationTime = Date.now();
+
+export type DiscoveryType = "bootstrap" | "peer-exchange" | "peer-cache";
+
+export interface DiscoveryEvent {
+ time: number;
+ type: DiscoveryType;
+ total: number;
+}
+
+export interface ConnectionEvent {
+ time: number;
+ total: number;
+}
+
+const discoveryCounts: Record = {
+ "bootstrap": 0,
+ "peer-exchange": 0,
+ "peer-cache": 0,
+};
+
+const discoveredAt = new Map();
+const countedByType: Record> = {
+ "bootstrap": new Set(),
+ "peer-exchange": new Set(),
+ "peer-cache": new Set(),
+};
+const connectionTimeline: ConnectionEvent[] = [];
+const discoveryTimeline: DiscoveryEvent[] = [];
export async function getWakuNode(): Promise {
if (wakuNodeInstance) {
@@ -38,6 +67,68 @@ export async function getWakuNode(): Promise {
await node.start();
await node.waitForPeers();
+ nodeCreationTime = Date.now();
+
+ node.libp2p.addEventListener("peer:discovery", (evt: any) => {
+ try {
+ const info = evt.detail; // PeerInfo
+ const peerId = info?.id?.toString?.() || info?.id || "";
+ if (!peerId) return;
+ if (!discoveredAt.has(peerId)) {
+ discoveredAt.set(peerId, Date.now());
+ }
+ } catch (_) {}
+ });
+
+ node.libp2p.addEventListener("peer:update", (evt: any) => {
+ try {
+ const update = evt.detail; // PeerUpdate
+ const peer = update?.peer;
+ const peerId = peer?.id?.toString?.() || peer?.id || "";
+ if (!peerId) return;
+ const peerTags = peer?.tags ?? {};
+ let hasBootstrap = false;
+ let hasPeerExchange = false;
+ let hasPeerCache = false;
+ if (peerTags instanceof Map) {
+ hasBootstrap = peerTags.has("bootstrap");
+ hasPeerExchange = peerTags.has("peer-exchange");
+ hasPeerCache = peerTags.has("peer-cache");
+ } else {
+ hasBootstrap = Object.prototype.hasOwnProperty.call(peerTags, "bootstrap");
+ hasPeerExchange = Object.prototype.hasOwnProperty.call(peerTags, "peer-exchange");
+ hasPeerCache = Object.prototype.hasOwnProperty.call(peerTags, "peer-cache");
+ }
+
+ const time = discoveredAt.get(peerId) ?? Date.now();
+
+ if (hasBootstrap && !countedByType["bootstrap"].has(peerId)) {
+ countedByType["bootstrap"].add(peerId);
+ discoveryCounts["bootstrap"]++;
+ discoveryTimeline.push({ time, type: "bootstrap", total: discoveryCounts["bootstrap"] });
+ }
+ if (hasPeerExchange && !countedByType["peer-exchange"].has(peerId)) {
+ countedByType["peer-exchange"].add(peerId);
+ discoveryCounts["peer-exchange"]++;
+ discoveryTimeline.push({ time, type: "peer-exchange", total: discoveryCounts["peer-exchange"] });
+ }
+ if (hasPeerCache && !countedByType["peer-cache"].has(peerId)) {
+ countedByType["peer-cache"].add(peerId);
+ discoveryCounts["peer-cache"]++;
+ discoveryTimeline.push({ time, type: "peer-cache", total: discoveryCounts["peer-cache"] });
+ }
+
+ (window as any).onDiscoveryUpdate?.([...discoveryTimeline]);
+ } catch (_) {}
+ });
+
+ node.libp2p.addEventListener("peer:connect", () => {
+ const now = Date.now();
+ const total = node.libp2p.getConnections().length;
+ connectionTimeline.push({ time: now, total });
+ (window as any).onConnectionsUpdate?.([...connectionTimeline]);
+ });
+
wakuNodeInstance = node;
return node;
}
@@ -57,3 +148,15 @@ export function createWakuDecoder() {
contentTopic: DEFAULT_CONTENT_TOPIC,
});
}
+
+export function getDiscoveryTimeline() {
+ return discoveryTimeline;
+}
+
+export function getConnectionTimeline() {
+ return connectionTimeline;
+}
+
+export function getNodeCreationTime() {
+ return nodeCreationTime;
+}