Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: trade
pr: 770
breaking: false
---

Reconciled confirmed ledger transactions with delayed indexer states using optimistic tracking and bounded catch-up polling.
8 changes: 8 additions & 0 deletions .changelog/unreleased/771-ob-116-pending-activity-overlay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: trade
pr: 771
breaking: false
---

Added a local pending activity overlay for trading actions while calculating available balance safely without inventing settled balances.
8 changes: 8 additions & 0 deletions .changelog/unreleased/780-ob-125-safe-caching-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: app
pr: 780
breaking: false
---

Configured safe Nitro caching route rules for immutable static assets, revalidating HTML, private financial endpoints, and short-TTL market feeds.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: app
pr: 781
breaking: false
---

Implemented bounded, privacy-safe telemetry tracking Core Web Vitals, route speed, market feed health, and isolated transaction refresh latency.
6 changes: 3 additions & 3 deletions apps/docs/public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</url>
<url>
<loc>https://docs.so4.market/concepts/order-types</loc>
<lastmod>2026-08-31</lastmod>
<lastmod>2026-09-26</lastmod>
</url>
<url>
<loc>https://docs.so4.market/concepts/perpetuals</loc>
Expand Down Expand Up @@ -102,11 +102,11 @@
</url>
<url>
<loc>https://docs.so4.market/guides/trading</loc>
<lastmod>2026-08-31</lastmod>
<lastmod>2026-09-26</lastmod>
</url>
<url>
<loc>https://docs.so4.market/guides/troubleshooting</loc>
<lastmod>2026-09-04</lastmod>
<lastmod>2026-09-26</lastmod>
</url>
<url>
<loc>https://docs.so4.market/reference/contracts.generated</loc>
Expand Down
6 changes: 6 additions & 0 deletions apps/web/nitro.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineNitroConfig } from "nitro/config"
import { getNitroRouteRules } from "./src/shared/lib/cache-headers"

export default defineNitroConfig({
routeRules: getNitroRouteRules(),
})
43 changes: 27 additions & 16 deletions apps/web/src/app/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Query } from "@tanstack/react-query"
import type { ReactNode } from "react"
import { normalizeQueryNetwork } from "@/shared/lib/query-keys"
import { useWalletStore } from "@/features/wallet/store/wallet-store"
import { boundQueryCache } from "@/features/trade/lib/cache-policy"

function queryClientDefaults() {
return {
Expand All @@ -33,7 +34,10 @@ export function getQueryClient(): QueryClient {
return createQueryClient()
}

browserQueryClient ??= createQueryClient()
if (!browserQueryClient) {
browserQueryClient = createQueryClient()
boundQueryCache(browserQueryClient)
}
return browserQueryClient
}

Expand All @@ -43,49 +47,56 @@ function isPrivateAccountQuery(
network: string,
): boolean {
const key = query.queryKey
return (
key[0] === "so4" &&
key[1] === normalizeQueryNetwork(network) &&
key.some((segment) => segment === account)
if (!Array.isArray(key)) return false
const normNetwork = normalizeQueryNetwork(network)
const keyNetwork = normalizeQueryNetwork(typeof key[1] === "string" ? key[1] : undefined)

// Matches any key containing the account address under the relevant network
const matchesAccount = key.some(
(segment) => typeof segment === "string" && segment.toLowerCase() === account.toLowerCase(),
)
return matchesAccount && keyNetwork === normNetwork
}

export async function clearPrivateAccountQueries(
client: QueryClient,
queryClient: QueryClient,
account: string,
network: string,
): Promise<void> {
const predicate = (query: Query) =>
isPrivateAccountQuery(query, account, network)
const queryCache = queryClient.getQueryCache()
const queries = queryCache.getAll()

await client.cancelQueries({ predicate })
client.removeQueries({ predicate })
for (const query of queries) {
if (isPrivateAccountQuery(query, account, network)) {
queryClient.removeQueries({ queryKey: query.queryKey, exact: true })
}
}
}

function AccountCacheLifecycle() {
const client = useQueryClient()
const account = useWalletStore((state) => state.address)
const network = useWalletStore((state) => state.network)
const previous = useRef({ account, network })
const previous = useRef<{ account: string | null; network: string | null }>({
account,
network: network ?? null,
})

useEffect(() => {
const old = previous.current
const accountChanged = old.account !== account
const networkChanged = old.network !== network
const networkChanged = old.network !== (network ?? null)

if (old.account && old.network && (accountChanged || networkChanged)) {
void clearPrivateAccountQueries(client, old.account, old.network)
}

previous.current = { account, network }
previous.current = { account, network: network ?? null }
}, [account, client, network])

return null
}

// Bound query cache to prevent unbounded growth across long sessions
boundQueryCache(queryClient)

export function QueryProvider({ children }: { children: ReactNode }) {
const client =
typeof window === "undefined" ? createQueryClient() : getQueryClient()
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/features/observability/hooks/usePerformanceMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react"
import { telemetry } from "../lib/telemetry"
import type { TelemetrySummary } from "../lib/telemetry"

export function usePerformanceMetrics(pollIntervalMs = 5000) {
const [summary, setSummary] = useState<TelemetrySummary>(() => telemetry.getSummary())

useEffect(() => {
const update = () => {
setSummary(telemetry.getSummary())
}

const interval = setInterval(update, pollIntervalMs)
return () => clearInterval(interval)
}, [pollIntervalMs])

return summary
}
93 changes: 93 additions & 0 deletions apps/web/src/features/observability/lib/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it } from "vitest"
import {
BoundedRingBuffer,
TelemetryManager,
sanitizeTelemetryString,
} from "./telemetry"

describe("telemetry (OB-126)", () => {
let mgr: TelemetryManager

beforeEach(() => {
mgr = new TelemetryManager()
})

describe("BoundedRingBuffer", () => {
it("drops oldest elements when exceeding capacity", () => {
const buffer = new BoundedRingBuffer<number>(3)
buffer.push(1)
buffer.push(2)
buffer.push(3)
expect(buffer.getAll()).toEqual([1, 2, 3])

buffer.push(4)
expect(buffer.getAll()).toEqual([2, 3, 4])
expect(buffer.size()).toBe(3)
})
})

describe("sanitizeTelemetryString (Privacy Guard)", () => {
it("scrubs Stellar public keys and contract addresses", () => {
const address = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
const contract = "CA3D5KRYMCMCZVAC7G3L7AITWB4YDTKTOWWLVODBRD5NOVI73PC5AZST"
const text = `User ${address} called contract ${contract}`
const sanitized = sanitizeTelemetryString(text)
expect(sanitized).toBe("User [SCRUBBED_ADDRESS] called contract [SCRUBBED_ADDRESS]")
expect(sanitized).not.toContain(address)
expect(sanitized).not.toContain(contract)
})

it("scrubs emails and query secrets", () => {
const input = "Contact user@example.com at /api/test?token=secret123&other=val"
const sanitized = sanitizeTelemetryString(input)
expect(sanitized).toBe("Contact [SCRUBBED_EMAIL] at /api/test?token=[SCRUBBED]&other=val")
expect(sanitized).not.toContain("user@example.com")
expect(sanitized).not.toContain("secret123")
})
})

describe("Route transition tracking", () => {
it("computes percentiles and device breakdowns", () => {
mgr.recordRouteTransition("/trade/xlm-usdc", "/trade/btc-usdc", 100, "desktop")
mgr.recordRouteTransition("/trade/btc-usdc", "/earn", 200, "desktop")
mgr.recordRouteTransition("/earn", "/trade/xlm-usdc", 300, "mobile")

const summary = mgr.getSummary()
expect(summary.routeTransitions.count).toBe(3)
expect(summary.routeTransitions.byDevice.desktop).toBe(2)
expect(summary.routeTransitions.byDevice.mobile).toBe(1)
expect(summary.routeTransitions.p50Ms).toBe(200)
})
})

describe("Feed health tracking", () => {
it("tracks disconnects and reconnection latency", () => {
mgr.recordFeedHealth("XLM-USDC", "connect")
mgr.recordFeedHealth("XLM-USDC", "disconnect")
mgr.recordFeedHealth("XLM-USDC", "reconnect", 1500)

const summary = mgr.getSummary()
expect(summary.feedHealth.disconnectCount).toBe(1)
expect(summary.feedHealth.avgReconnectMs).toBe(1500)
expect(summary.feedHealth.activeFeeds).toContain("XLM-USDC")
})
})

describe("Transaction latency isolation", () => {
it("isolates client prep, network confirmation, and indexer catch-up stages", () => {
mgr.recordTransactionLatency({
operationType: "CREATE_ORDER",
clientPrepMs: 250,
networkConfirmMs: 3200,
indexerCatchupMs: 1100,
})

const summary = mgr.getSummary()
expect(summary.transactionLatency.count).toBe(1)
expect(summary.transactionLatency.avgClientPrepMs).toBe(250)
expect(summary.transactionLatency.avgNetworkConfirmMs).toBe(3200)
expect(summary.transactionLatency.avgIndexerCatchupMs).toBe(1100)
expect(summary.transactionLatency.avgTotalMs).toBe(4550)
})
})
})
Loading