From ff384bc71f8d54f30b8802df748e3176ad18f539 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:08:21 +0200 Subject: [PATCH] feat(web): monitor CTAs on free tools, checker deep links and signup attribution (#2975) * feat(web): monitor CTAs on free tools, checker deep links and signup attribution - speed checker: "Monitor this URL" box after results, prefill via ?url= and ?method= - cURL builder: run the same request from 28 regions or monitor the endpoint - status directory: top link tests the service from 28 regions; component pages link their parent - sharper titles and descriptions for the speed checker, SLA calculator and latency vs response time doc - first-touch attribution cookie on .openstatus.dev, carried onto user_created - onboarding skip/complete events through tracking-only tRPC procedures (track router) - brand casing: openstatus, not OpenStatus, in status directory and tool copy * fix: address review on tool CTAs and attribution - monitor prefill accepts method, so the cURL builder hands over POST/PUT too - cURL links say "same URL" since headers and body stay in the command - checker CTA has a single-region message instead of claiming an even spread - component page links its parent relatively, not through BASE_URL - referrer check matches openstatus.dev exactly or as a dot-suffix - latency doc: phase names match the table, region count is 28 - tests for getHost and getCheckerHref; onboarding comment says once per mount * style(checker): spacing and full-width button on the monitor box --- .../app/(dashboard)/monitors/create/page.tsx | 8 +- .../monitors/create/search-params.ts | 1 + apps/dashboard/src/app/onboarding/client.tsx | 28 ++++++- apps/dashboard/src/lib/auth/index.ts | 12 ++- apps/dashboard/src/proxy.ts | 35 ++++++++- .../src/app/(landing)/play/checker/client.tsx | 70 ++++++++++++++++- .../src/app/(landing)/play/checker/page.tsx | 32 +++++++- .../(landing)/play/checker/search-params.ts | 3 + .../src/app/(landing)/play/curl/client.tsx | 40 ++++++++++ .../[id]/[component]/component-detail.tsx | 3 + .../status/[id]/[component]/page.tsx | 32 +++++--- .../src/app/(landing)/status/[id]/page.tsx | 33 +++++--- .../src/app/(landing)/status/utils.test.ts | 27 ++++++- apps/web/src/app/(landing)/status/utils.ts | 22 ++++++ apps/web/src/app/layout.tsx | 2 + apps/web/src/components/attribution.tsx | 58 ++++++++++++++ .../docs/concept/latency-vs-response-time.mdx | 10 ++- .../src/content/pages/tools/cdn-checker.mdx | 6 +- apps/web/src/content/pages/tools/checker.mdx | 8 +- .../src/content/pages/tools/uptime-sla.mdx | 4 +- apps/web/src/content/status-markdown.ts | 6 +- packages/analytics/src/attribution.ts | 75 +++++++++++++++++++ packages/analytics/src/index.ts | 1 + packages/api/src/edge.ts | 2 + packages/api/src/router/track.ts | 23 ++++++ 25 files changed, 493 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/attribution.tsx create mode 100644 packages/analytics/src/attribution.ts create mode 100644 packages/api/src/router/track.ts diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx index 6d62bc997..98cbb7476 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx @@ -1,6 +1,7 @@ "use client"; import { headerAssertion } from "@openstatus/assertions"; +import { MONITOR_METHODS } from "@openstatus/utils"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; @@ -34,6 +35,7 @@ function safeHostname(url: string) { function buildPrefill(params: { url: string | null; name: string | null; + method: string | null; assertionHeaderKey: string | null; assertionHeaderCompare: string | null; assertionHeaderValue: string | null; @@ -48,11 +50,15 @@ function buildPrefill(params: { target: params.assertionHeaderValue, }); + const method = MONITOR_METHODS.find( + (m) => m === params.method?.toUpperCase(), + ); + return { active: true, name: params.name ?? safeHostname(params.url), type: "http", - method: "GET", + method: method ?? "GET", url: params.url, headers: [], body: "", diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts index 66d3fd07c..47d9603b6 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts @@ -4,6 +4,7 @@ import { createSearchParamsCache, parseAsString } from "nuqs/server"; export const searchParamsParsers = { url: parseAsString, name: parseAsString, + method: parseAsString, assertionHeaderKey: parseAsString, assertionHeaderCompare: parseAsString, assertionHeaderValue: parseAsString, diff --git a/apps/dashboard/src/app/onboarding/client.tsx b/apps/dashboard/src/app/onboarding/client.tsx index ffc90cf62..355e332e2 100644 --- a/apps/dashboard/src/app/onboarding/client.tsx +++ b/apps/dashboard/src/app/onboarding/client.tsx @@ -76,6 +76,24 @@ export function Client() { const pathname = usePathname(); const trpc = useTRPC(); const queryClient = useQueryClient(); + const skipMutation = useMutation(trpc.track.onboardingSkip.mutationOptions()); + const completeMutation = useMutation( + trpc.track.onboardingComplete.mutationOptions(), + ); + const trackSkip = (step: "monitor" | "page") => { + skipMutation.mutate({ step }); + }; + + // Once per mount; the search params say how they reached the launch step. + const completedRef = useRef(false); + useEffect(() => { + if (step !== "3" || completedRef.current) return; + completedRef.current = true; + completeMutation.mutate({ + monitor: monitor ?? "none", + page: page ?? "none", + }); + }, [step, monitor, page, completeMutation]); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: user } = useQuery(trpc.user.get.queryOptions()); @@ -215,7 +233,10 @@ export function Client() { active: true, }); }} - onSkip={() => setSearchParams({ monitor: "skipped", step: "2" })} + onSkip={() => { + trackSkip("monitor"); + setSearchParams({ monitor: "skipped", step: "2" }); + }} onContinue={() => { setSearchParams({ step: "2" }); stopChecks(); @@ -267,7 +288,10 @@ export function Client() { }); } }} - onSkip={() => setSearchParams({ page: "skipped", step: "3" })} + onSkip={() => { + trackSkip("page"); + setSearchParams({ page: "skipped", step: "3" }); + }} onContinue={() => setSearchParams({ step: "3" })} /> ) : null} diff --git a/apps/dashboard/src/lib/auth/index.ts b/apps/dashboard/src/lib/auth/index.ts index 829b352c6..cf99b6523 100644 --- a/apps/dashboard/src/lib/auth/index.ts +++ b/apps/dashboard/src/lib/auth/index.ts @@ -1,4 +1,8 @@ -import { Events, setupAnalytics } from "@openstatus/analytics"; +import { + Events, + readAttributionCookie, + setupAnalytics, +} from "@openstatus/analytics"; import { db, eq } from "@openstatus/db"; import { type User, user } from "@openstatus/db/src/schema"; import { WelcomeEmail, sendEmail } from "@openstatus/emails"; @@ -76,7 +80,11 @@ async function onNewUser( source: "dashboard", }); - await analytics.track({ ...Events.CreateUser, provider }); + await analytics.track({ + ...Events.CreateUser, + provider, + ...readAttributionCookie(requestHeaders.get("cookie")), + }); } const { diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts index 7ed7fbc88..ee4231b2c 100644 --- a/apps/dashboard/src/proxy.ts +++ b/apps/dashboard/src/proxy.ts @@ -1,15 +1,46 @@ +import { + ATTRIBUTION_COOKIE, + ATTRIBUTION_DOMAIN, + ATTRIBUTION_MAX_AGE, + parseAttribution, + serializeAttribution, +} from "@openstatus/analytics/src/attribution"; import { asc, db, eq } from "@openstatus/db"; import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { SSO_ORG_COOKIE } from "@/lib/sso-cookie"; import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; +// Marketing CTAs land here as `?ref=home-hero`. Fold the ref into the +// first-touch cookie the web app set (or start one) so `user_created` can +// carry it; the ref is dropped by the login redirect otherwise. +function withAttributionRef(req: NextRequest, response: NextResponse) { + const ref = req.nextUrl.searchParams.get("ref"); + if (!ref) return response; + const current = parseAttribution(req.cookies.get(ATTRIBUTION_COOKIE)?.value); + if (current.ref) return response; + const onProd = req.nextUrl.hostname.endsWith(ATTRIBUTION_DOMAIN.slice(1)); + response.cookies.set( + ATTRIBUTION_COOKIE, + serializeAttribution({ ...current, ref }), + { + path: "/", + maxAge: ATTRIBUTION_MAX_AGE, + sameSite: "lax", + secure: onProd, + ...(onProd ? { domain: ATTRIBUTION_DOMAIN } : {}), + }, + ); + return response; +} + export default auth(async (req) => { const url = req.nextUrl.clone(); - const response = NextResponse.next(); + const response = withAttributionRef(req, NextResponse.next()); const continent = req.headers.get("x-vercel-ip-continent") || "NA"; const country = req.headers.get("x-vercel-ip-country") || "US"; @@ -36,7 +67,7 @@ export default auth(async (req) => { newURL.searchParams.append("redirectTo", encodedSearchParams); } - const response = NextResponse.redirect(newURL); + const response = withAttributionRef(req, NextResponse.redirect(newURL)); // Store the redirect URL in a cookie for new users who go through onboarding. // Auth.js may not reliably pass callbackUrl to pages.newUser, so we use a // cookie as a fallback to ensure invite links work correctly. diff --git a/apps/web/src/app/(landing)/play/checker/client.tsx b/apps/web/src/app/(landing)/play/checker/client.tsx index 67f0a6727..a257383fa 100644 --- a/apps/web/src/app/(landing)/play/checker/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/client.tsx @@ -30,6 +30,7 @@ import { latencyFormatter, regionFormatter, } from "../../../../lib/checker/utils"; +import { APP_URL } from "../../../../lib/metadata/shared-metadata"; import { toast } from "../../../../lib/toast"; import { cn } from "../../../../lib/utils"; import { @@ -50,6 +51,9 @@ type CheckerContextType = { setFailures: React.Dispatch>; id: string | null; setId: React.Dispatch>; + // URL the current `values` belong to, for the monitor CTA. + url: string | null; + setUrl: React.Dispatch>; }; const CheckerContext = createContext({ @@ -59,17 +63,22 @@ const CheckerContext = createContext({ setFailures: () => {}, id: null, setId: () => {}, + url: null, + setUrl: () => {}, }); export function CheckerProvider({ children, defaultValues = [], + defaultUrl = null, }: { children: React.ReactNode; defaultValues?: Values[]; + defaultUrl?: string | null; }) { const [values, setValues] = useState(defaultValues); const [failures, setFailures] = useState([]); + const [url, setUrl] = useState(defaultUrl); const [{ id: urlId }, setSearchParams] = useQueryStates(searchParamsParsers); const [id, setId] = useState(urlId); @@ -96,6 +105,8 @@ export function CheckerProvider({ setFailures, id, setId: updateId, + url, + setUrl, }} > {children} @@ -118,7 +129,7 @@ export function Form({ defaultMethod?: string; defaultUrl?: string; }) { - const { setValues, setFailures, setId } = useCheckerContext(); + const { setValues, setFailures, setId, setUrl } = useCheckerContext(); const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -141,6 +152,7 @@ export function Form({ setValues([]); setFailures([]); setId(null); // This will also clear the URL param + setUrl(url); startTransition(async () => { async function fetchAndReadStream() { @@ -487,6 +499,62 @@ export function DetailsButtonLink() { ); } +// Below 3× the regions are "even"; above it one region is the story. +const SPREAD_RATIO = 3; + +export function MonitorCta() { + const { values, url } = useCheckerContext(); + + if (!url || values.length === 0) return null; + + const sorted = [...values].sort((a, b) => a.latency - b.latency); + const fastest = sorted[0]; + const slowest = sorted[sorted.length - 1]; + const uneven = + sorted.length > 1 && + fastest.latency > 0 && + slowest.latency / fastest.latency >= SPREAD_RATIO; + + const params = new URLSearchParams({ url, ref: "checker" }); + + return ( +
+
+

+ {uneven + ? `${regionFormatter(slowest.region, "long")} answers in ${latencyFormatter( + slowest.latency, + )}, ${regionFormatter(fastest.region, "long")} in ${latencyFormatter( + fastest.latency, + )}.` + : sorted.length > 1 + ? "Response times look even across regions." + : "Only one region responded."} +

+

+ {uneven + ? "One sample is a snapshot. Monitor this URL from all regions and get alerted when a region degrades. Free." + : sorted.length > 1 + ? "Keep it that way: monitor this URL on a schedule and get alerted when it slows down or fails. Free." + : "Monitor this URL on a schedule to see whether the other regions reach it and get alerted when it fails. Free."} +

+
+ +
+ ); +} + export function ExportToCSVButton() { const { values } = useCheckerContext(); diff --git a/apps/web/src/app/(landing)/play/checker/page.tsx b/apps/web/src/app/(landing)/play/checker/page.tsx index 612c0b11a..b9dfc1a4d 100644 --- a/apps/web/src/app/(landing)/play/checker/page.tsx +++ b/apps/web/src/app/(landing)/play/checker/page.tsx @@ -21,23 +21,44 @@ import { DetailsButtonLink, ExportToCSVButton, Form, + MonitorCta, ResponseStatus, ResultTable, } from "./client"; import { searchParamsCache } from "./search-params"; +const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + +function safeUrl(value: string | null): string | undefined { + if (!value) return undefined; + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + return parsed.toString(); + } catch { + return undefined; + } +} + export function generateMetadata(): Metadata { const page = getToolsPage("checker"); return getPageMetadata(page, "play"); } export default async function Page(props: { - searchParams: Promise<{ id: string }>; + searchParams: Promise<{ id?: string; url?: string; method?: string }>; }) { const page = getToolsPage("checker"); const searchParams = await props.searchParams; - const { id } = searchParamsCache.parse(searchParams); + const { id, url, method } = searchParamsCache.parse(searchParams); + const prefillUrl = safeUrl(url); + const prefillMethod = + method && METHODS.includes(method.toUpperCase()) + ? method.toUpperCase() + : undefined; const data = id ? process.env.NODE_ENV === "development" @@ -63,14 +84,19 @@ export default async function Page(props: {

{page.metadata.description}

a.latency - b.latency)} + defaultUrl={data?.url} > -
+
+ diff --git a/apps/web/src/app/(landing)/play/checker/search-params.ts b/apps/web/src/app/(landing)/play/checker/search-params.ts index fc70c0e3c..7510ea6bd 100644 --- a/apps/web/src/app/(landing)/play/checker/search-params.ts +++ b/apps/web/src/app/(landing)/play/checker/search-params.ts @@ -2,6 +2,9 @@ import { createSearchParamsCache, parseAsString } from "nuqs/server"; export const searchParamsParsers = { id: parseAsString, + // Deep-link prefill (status directory, cURL builder): never runs a check. + url: parseAsString, + method: parseAsString, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/web/src/app/(landing)/play/curl/client.tsx b/apps/web/src/app/(landing)/play/curl/client.tsx index 7bfb8526e..28183cd65 100644 --- a/apps/web/src/app/(landing)/play/curl/client.tsx +++ b/apps/web/src/app/(landing)/play/curl/client.tsx @@ -13,8 +13,11 @@ import { } from "@openstatus/ui/components/ui/select"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; +import Link from "next/link"; import { Fragment, useState } from "react"; +import { APP_URL } from "@/lib/metadata/shared-metadata"; + type Values = { url: string; method: string; @@ -239,11 +242,48 @@ export function Form() { {isCopied ? "Copied" : "Copy to Clipboard"} + ); } +function isHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +// The command is a one-off from the reader's machine. Both links carry the +// URL and method only; headers and body stay in the command. +function NextSteps({ url, method }: { url: string; method: string }) { + if (!isHttpUrl(url)) return null; + + const checker = new URLSearchParams({ url, method }); + const monitor = new URLSearchParams({ url, method, ref: "curl" }); + + return ( +

+ Same URL, different place:{" "} + + run it from 28 regions + {" "} + or{" "} + + monitor this endpoint + {" "} + and get alerted when it fails. +

+ ); +} + function generateCurlCommand(values?: Values) { if (!values) return ""; diff --git a/apps/web/src/app/(landing)/status/[id]/[component]/component-detail.tsx b/apps/web/src/app/(landing)/status/[id]/[component]/component-detail.tsx index 6023623c2..9ab52628c 100644 --- a/apps/web/src/app/(landing)/status/[id]/[component]/component-detail.tsx +++ b/apps/web/src/app/(landing)/status/[id]/[component]/component-detail.tsx @@ -179,6 +179,9 @@ export function ComponentDetail({

Is {fullName} down?

+

+ Part of {service.name} status +

{answer} Below you'll find the live {component.name} status, uptime over the last {days} days, and recent incidents affecting {component.name}. diff --git a/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx b/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx index fbffb6dcf..d32947f69 100644 --- a/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx +++ b/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx @@ -18,7 +18,7 @@ import { ContentBoxDescription, ContentBoxTitle, } from "../../../content-box"; -import { getComponentFullName } from "../../utils"; +import { getCheckerHref, getComponentFullName, getHost } from "../../utils"; import { ComponentDetail } from "./component-detail"; export const dynamic = "force-dynamic"; @@ -41,7 +41,7 @@ export async function generateMetadata(args: { const fullName = getComponentFullName(service.name, component.name); const title = `Is ${fullName} Down? ${fullName} Status & History`; - const description = `Is ${component.name} (${service.name}) down right now? Check the live status, uptime over the last ${HISTORY_DAYS} days, and recent incidents for ${component.name} tracked by OpenStatus.`; + const description = `Is ${component.name} (${service.name}) down right now? Check the live status, uptime over the last ${HISTORY_DAYS} days, and recent incidents for ${component.name}, part of the ${service.name} status tracked by openstatus.`; const canonicalUrl = `${BASE_URL}/status/${service.slug}/${component.slug}`; const ogImage = `${BASE_URL}/api/og/external-service?slug=${encodeURIComponent(service.slug)}&component=${encodeURIComponent(component.slug)}`; const indexable = service.deletedAt == null && !component.stale; @@ -86,16 +86,28 @@ export default async function Page(args: { params: Promise }) { days: HISTORY_DAYS, }); + const checkerHref = getCheckerHref(service.url); + return (

- - Catch downtime instantly and keep your users in the loop with - OpenStatus → - + {checkerHref ? ( + + Is {service.name} down for you? Test {getHost(service.url)} from 28 + regions → + + ) : ( + + Catch downtime before your users do: free uptime monitoring from 28 + regions → + + )} @@ -121,7 +133,7 @@ export default async function Page(args: { params: Promise }) { Looking for a status page? - Every service needs a status page. Run yours with OpenStatus — see + Every service needs a status page. Run yours with openstatus — see what a{" "} hosted status page diff --git a/apps/web/src/app/(landing)/status/[id]/page.tsx b/apps/web/src/app/(landing)/status/[id]/page.tsx index 915bfbd59..d86efc521 100644 --- a/apps/web/src/app/(landing)/status/[id]/page.tsx +++ b/apps/web/src/app/(landing)/status/[id]/page.tsx @@ -20,6 +20,7 @@ import { ContentBoxDescription, ContentBoxTitle, } from "../../content-box"; +import { getCheckerHref, getHost } from "../utils"; import { ServiceDetail } from "./service-detail"; export const dynamic = "force-dynamic"; @@ -42,8 +43,8 @@ export async function generateMetadata(args: { ? `Users reporting issues with ${service.name}. ${service.name} Status & Incidents` : `Is ${service.name} Down? ${service.name} Status & Incidents`; const description = escalated - ? `Users are reporting problems with ${service.name} in the last ${REPORT_WINDOW_MINUTES} minutes. Check the live ${service.name} status, uptime over the last ${HISTORY_DAYS} days, and recent ${service.name} incidents tracked by OpenStatus.` - : `Is ${service.name} down right now? Check the live ${service.name} status, uptime over the last ${HISTORY_DAYS} days, and recent ${service.name} incidents tracked by OpenStatus.`; + ? `Users are reporting problems with ${service.name} in the last ${REPORT_WINDOW_MINUTES} minutes. Check the live ${service.name} status, uptime over the last ${HISTORY_DAYS} days, and recent ${service.name} incidents tracked by openstatus.` + : `Is ${service.name} down right now? Check the live ${service.name} status, uptime over the last ${HISTORY_DAYS} days, and recent ${service.name} incidents tracked by openstatus.`; const canonicalUrl = `${BASE_URL}/status/${service.slug}`; const ogImage = `${BASE_URL}/api/og/external-service?slug=${encodeURIComponent(service.slug)}`; const indexable = service.deletedAt == null; @@ -104,16 +105,28 @@ export default async function Page(args: { params: Promise }) { }), ]); + const checkerHref = getCheckerHref(service.url); + return (
- - Catch downtime instantly and keep your users in the loop with - OpenStatus → - + {checkerHref ? ( + + Is {service.name} down for you? Test {getHost(service.url)} from 28 + regions → + + ) : ( + + Catch downtime before your users do: free uptime monitoring from 28 + regions → + + )} @@ -135,7 +148,7 @@ export default async function Page(args: { params: Promise }) { Looking for a status page? - Every service needs a status page. Run yours with OpenStatus — see + Every service needs a status page. Run yours with openstatus — see what a{" "} hosted status page diff --git a/apps/web/src/app/(landing)/status/utils.test.ts b/apps/web/src/app/(landing)/status/utils.test.ts index 140810686..936051ea9 100644 --- a/apps/web/src/app/(landing)/status/utils.test.ts +++ b/apps/web/src/app/(landing)/status/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getComponentFullName } from "./utils"; +import { getCheckerHref, getComponentFullName, getHost } from "./utils"; describe("getComponentFullName", () => { test("does not repeat the provider already in the component name", () => { @@ -49,3 +49,28 @@ describe("getComponentFullName", () => { expect(getComponentFullName(" ", "")).toBe(""); }); }); + +describe("getHost", () => { + test("returns the hostname without a www prefix", () => { + expect(getHost("https://www.github.com/")).toBe("github.com"); + expect(getHost("https://api.vercel.com/v1")).toBe("api.vercel.com"); + }); + + test("falls back to the input when it is not a URL", () => { + expect(getHost("not a url")).toBe("not a url"); + }); +}); + +describe("getCheckerHref", () => { + test("links the checker with the url encoded", () => { + expect(getCheckerHref("https://github.com")).toBe( + "/play/checker?url=https%3A%2F%2Fgithub.com%2F", + ); + }); + + test("refuses non-http schemes and invalid urls", () => { + expect(getCheckerHref("ftp://files.example.com")).toBeNull(); + expect(getCheckerHref("javascript:alert(1)")).toBeNull(); + expect(getCheckerHref("not a url")).toBeNull(); + }); +}); diff --git a/apps/web/src/app/(landing)/status/utils.ts b/apps/web/src/app/(landing)/status/utils.ts index c433d6d41..4994a12dc 100644 --- a/apps/web/src/app/(landing)/status/utils.ts +++ b/apps/web/src/app/(landing)/status/utils.ts @@ -17,6 +17,28 @@ export function isStale(fetchedAtMs: number): boolean { return Date.now() - fetchedAtMs > STALE_THRESHOLD_MS; } +export function getHost(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +// Directory rows carry the marketing site, which is what a visitor asking +// "is it down for me?" wants probed; only http(s) can be checked. +export function getCheckerHref(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return `/play/checker?${new URLSearchParams({ url: parsed.toString() })}`; + } catch { + return null; + } +} + // Upstream component names often already carry the provider ("Zoom AI" on // Zoom), so prefixing blindly yields "Zoom Zoom AI". export function getComponentFullName( diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a4059156e..6dba7a499 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -8,6 +8,7 @@ import LocalFont from "next/font/local"; import Script from "next/script"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Attribution } from "../components/attribution"; import { ThemeProvider } from "../components/theme-provider"; import { WebMcpProvider } from "../components/webmcp-provider"; import { env } from "../env"; @@ -78,6 +79,7 @@ export default function RootLayout({ {children} + diff --git a/apps/web/src/components/attribution.tsx b/apps/web/src/components/attribution.tsx new file mode 100644 index 000000000..26670c0ff --- /dev/null +++ b/apps/web/src/components/attribution.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { + ATTRIBUTION_COOKIE, + ATTRIBUTION_DOMAIN, + ATTRIBUTION_MAX_AGE, + type Attribution, + serializeAttribution, +} from "@openstatus/analytics/src/attribution"; +import { useEffect } from "react"; + +const PARAMS = ["ref", "utm_source", "utm_medium", "utm_campaign"] as const; + +function referrerHost(): string | undefined { + if (!document.referrer) return undefined; + try { + const host = new URL(document.referrer).hostname; + const own = ATTRIBUTION_DOMAIN.slice(1); + return host === own || host.endsWith(ATTRIBUTION_DOMAIN) ? undefined : host; + } catch { + return undefined; + } +} + +// First touch wins: an existing cookie is left alone so the landing page that +// brought the visitor here survives later navigation and the app hand-off. +export function Attribution() { + useEffect(() => { + try { + const present = document.cookie + .split(";") + .some((part) => part.trim().startsWith(`${ATTRIBUTION_COOKIE}=`)); + if (present) return; + + const params = new URLSearchParams(window.location.search); + const attribution: Attribution = { + landing_path: window.location.pathname, + landing_referrer: referrerHost(), + }; + for (const key of PARAMS) { + const value = params.get(key); + if (value) attribution[key] = value; + } + + const onProd = window.location.hostname.endsWith( + ATTRIBUTION_DOMAIN.slice(1), + ); + const scope = onProd ? `; domain=${ATTRIBUTION_DOMAIN}; secure` : ""; + document.cookie = `${ATTRIBUTION_COOKIE}=${encodeURIComponent( + serializeAttribution(attribution), + )}; path=/; max-age=${ATTRIBUTION_MAX_AGE}; samesite=lax${scope}`; + } catch { + // Cookies blocked: attribution is best effort. + } + }, []); + + return null; +} diff --git a/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx b/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx index ae3e4c45e..e6273e6bb 100644 --- a/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx +++ b/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx @@ -1,7 +1,9 @@ --- category: Concepts title: Understanding Latency vs Response Time -description: "Deep dive into the difference between latency and response time, and why both matter for monitoring" +description: "Latency is network travel time, response time adds server processing. What each one measures, how to read them per region, and which one to monitor." +seo: + title: "Latency vs Response Time: The Difference and How to Monitor Both" --- ## The confusion @@ -12,7 +14,7 @@ Latency and response time are often used interchangeably, but they measure diffe - **Latency** measures network travel time - **Response time** measures total time including server processing -Both metrics matter, but for different reasons. This page explains what each one measures and how to read them; if you are looking for target numbers, see [what is a good server response time?](/guides/what-is-a-good-response-time). +Both metrics matter, but for different reasons. This page explains what each one measures and how to read them; if you are looking for target numbers, see [what is a good server response time?](/guides/what-is-a-good-response-time). To see both for your own URL, split per region into DNS, connect, TLS, TTFB and transfer, run the [website speed test](/play/checker). ## What is latency? @@ -150,7 +152,7 @@ By monitoring both metrics, you can quickly pinpoint whether a performance slowd You need two things: the phase breakdown, and more than one vantage point. **For a single check right now**, run the URL through the -[global speed test](/play/checker). It requests from 27 regions in parallel and +[global speed test](/play/checker). It requests from 28 regions in parallel and returns all five phases per region, which is enough to tell a distance problem from an application problem in one pass. No account required. @@ -186,7 +188,7 @@ Latency and response time are the raw measurements. These build on top of them: ## Next steps -- **[Run a one-off speed test](/play/checker)** — see the timing breakdown for your own URL from 27 regions, no account needed. +- **[Run a one-off speed test](/play/checker)** — see the timing breakdown for your own URL from 28 regions, no account needed. - **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — start tracking these metrics. - **[Understanding uptime monitoring](/docs/concept/uptime-monitoring)** — broader monitoring concepts. - **[Response logs and data retention](/docs/concept/response-logs-and-retention)** — where the per-phase timing shows up for every check. diff --git a/apps/web/src/content/pages/tools/cdn-checker.mdx b/apps/web/src/content/pages/tools/cdn-checker.mdx index 28dc24808..c8dd4ae19 100644 --- a/apps/web/src/content/pages/tools/cdn-checker.mdx +++ b/apps/web/src/content/pages/tools/cdn-checker.mdx @@ -6,7 +6,7 @@ description: "Check if your CDN is actually caching from 28 regions worldwide. S category: "Product" faq: - question: "What is a CDN cache checker?" - answer: "A CDN cache checker tests whether your CDN is serving content from its edge cache instead of your origin server. OpenStatus requests your URL from 28 regions worldwide and reads the cache headers (cf-cache-status, x-cache, x-vercel-cache, age) to report HIT, MISS, EXPIRED, STALE, BYPASS or DYNAMIC per region." + answer: "A CDN cache checker tests whether your CDN is serving content from its edge cache instead of your origin server. openstatus requests your URL from 28 regions worldwide and reads the cache headers (cf-cache-status, x-cache, x-vercel-cache, age) to report HIT, MISS, EXPIRED, STALE, BYPASS or DYNAMIC per region." - question: "Why does my CDN return MISS in some regions?" answer: "CDN caches are regional: each edge location keeps its own copy. A MISS in a region usually means no user has requested the asset from that edge recently, the TTL expired, or your cache rules exclude it. The first request from a region is always a MISS — run the check again to confirm whether the edge cached the response." - question: "How do I know what CDN a website is using?" @@ -89,7 +89,7 @@ Need a more complex request? Build it with the [cURL Builder](/play/curl). Want --- -With OpenStatus, you can: +With openstatus, you can: - Verify your CDN serves cache HITs from every region after a config change. - Debug `cf-cache-status: MISS` and origin load spikes region by region. @@ -102,7 +102,7 @@ If you'd like to request additional test regions or providers, feel free to cont
-A CDN cache checker tests whether your CDN is serving content from its edge cache instead of your origin server. OpenStatus requests your URL from 28 regions worldwide and reads the cache headers (cf-cache-status, x-cache, x-vercel-cache, age) to report HIT, MISS, EXPIRED, STALE, BYPASS or DYNAMIC per region. +A CDN cache checker tests whether your CDN is serving content from its edge cache instead of your origin server. openstatus requests your URL from 28 regions worldwide and reads the cache headers (cf-cache-status, x-cache, x-vercel-cache, age) to report HIT, MISS, EXPIRED, STALE, BYPASS or DYNAMIC per region.
diff --git a/apps/web/src/content/pages/tools/checker.mdx b/apps/web/src/content/pages/tools/checker.mdx index ce24e296c..a056c2d96 100644 --- a/apps/web/src/content/pages/tools/checker.mdx +++ b/apps/web/src/content/pages/tools/checker.mdx @@ -2,11 +2,11 @@ title: "Global Speed Checker" publishedAt: "2025-11-10" author: "Thibault Le Ouay Ducasse" -description: "Free website speed test. Check your site's load time and response time from 28 regions worldwide in seconds - no signup, no install required." +description: "Test your website's load time and latency from 28 locations at once. You get DNS, TLS and time to first byte per region in about 20 seconds. Free, no signup." category: "Product" hero: Website Speed Test - Check Load Time Worldwide seo: - title: "Website Speed Test - Check Load Time from 28 Global Regions" + title: "Free Website Speed Test from 28 Locations Worldwide" howto: totalTime: "PT20S" steps: @@ -42,9 +42,9 @@ faq:
-**API & Website Speed** +**API & Website Speed Test** -Test Enter your URL and get a website speed check. Get insights on page load, header details and timing phases (DNS, Connect, TLS, TTFB, Transfer) of the response. +Enter your URL and get a website speed check. Get insights on page load, header details and timing phases (DNS, Connect, TLS, TTFB, Transfer) of the response.
diff --git a/apps/web/src/content/pages/tools/uptime-sla.mdx b/apps/web/src/content/pages/tools/uptime-sla.mdx index 757783455..68f3bb78f 100644 --- a/apps/web/src/content/pages/tools/uptime-sla.mdx +++ b/apps/web/src/content/pages/tools/uptime-sla.mdx @@ -1,8 +1,8 @@ --- title: "SLA Calculator" hero: "SLA & Uptime Calculator - Downtime per 99.9%" -seo: - title: "SLA & Uptime Calculator — Downtime per 99.9%" +seo: + title: "SLA Uptime Calculator: Downtime for 99.9%, 99.99% and 99.999%" publishedAt: "2025-11-10" author: "Thibault Le Ouay Ducasse" description: "Free SLA uptime calculator. Enter any target (99%, 99.9%, 99.99%) to see allowed downtime per day, month and year. Instant results, no signup." diff --git a/apps/web/src/content/status-markdown.ts b/apps/web/src/content/status-markdown.ts index ba4fc2b98..03daf0a48 100644 --- a/apps/web/src/content/status-markdown.ts +++ b/apps/web/src/content/status-markdown.ts @@ -150,10 +150,10 @@ export async function generateStatusIndexMarkdown(): Promise { let md = "---\n"; md += `title: "External Status"\n`; - md += `description: "Current status of external services tracked by OpenStatus."\n`; + md += `description: "Current status of external services tracked by openstatus."\n`; md += "---\n\n"; md += "# External Status\n\n"; - md += "Current status of external services tracked by OpenStatus.\n\n"; + md += "Current status of external services tracked by openstatus.\n\n"; md += "| Service | Status | Provider | URL |\n"; md += "| --- | --- | --- | --- |\n"; for (const s of services) { @@ -201,7 +201,7 @@ export async function generateStatusDetailMarkdown( let md = "---\n"; md += `title: "${service.name} Status"\n`; - md += `description: "Current status of ${service.name}. Uptime history and recent incidents tracked by OpenStatus."\n`; + md += `description: "Current status of ${service.name}. Uptime history and recent incidents tracked by openstatus."\n`; md += "---\n\n"; md += `# ${service.name} Status\n\n`; diff --git a/packages/analytics/src/attribution.ts b/packages/analytics/src/attribution.ts new file mode 100644 index 000000000..68e63bf6c --- /dev/null +++ b/packages/analytics/src/attribution.ts @@ -0,0 +1,75 @@ +// First-touch attribution shared by the marketing site (writer) and the +// dashboard (reader). Lives on `.openstatus.dev` so a visit to www can be +// joined to the `user_created` event the dashboard emits later. + +export const ATTRIBUTION_COOKIE = "os_attr"; +export const ATTRIBUTION_MAX_AGE = 30 * 24 * 60 * 60; +export const ATTRIBUTION_DOMAIN = ".openstatus.dev"; + +// `landing_referrer`, not `referrer`: openpanel already has a `referrer` +// column on every event and a same-named property would be ambiguous. +const KEYS = [ + "landing_path", + "landing_referrer", + "ref", + "utm_source", + "utm_medium", + "utm_campaign", +] as const; + +export type Attribution = Partial>; + +const MAX_VALUE_LENGTH = 200; + +export function sanitizeAttribution(input: unknown): Attribution { + if (!input || typeof input !== "object") return {}; + const out: Attribution = {}; + for (const key of KEYS) { + const value = (input as Record)[key]; + if (typeof value === "string" && value.length > 0) { + out[key] = value.slice(0, MAX_VALUE_LENGTH); + } + } + return out; +} + +// Plain JSON. Next's cookie helpers URL-encode on set and decode on read; +// `document.cookie` does neither, so the web writer encodes it itself. +export function serializeAttribution(attribution: Attribution): string { + return JSON.stringify(sanitizeAttribution(attribution)); +} + +export function parseAttribution(raw: string | undefined | null): Attribution { + if (!raw) return {}; + for (const candidate of [raw, safeDecode(raw)]) { + if (candidate === null) continue; + try { + return sanitizeAttribution(JSON.parse(candidate)); + } catch { + // try the next form + } + } + return {}; +} + +function safeDecode(value: string): string | null { + try { + const decoded = decodeURIComponent(value); + return decoded === value ? null : decoded; + } catch { + return null; + } +} + +// For a raw `Cookie` header, where the value is still URL-encoded. +export function readAttributionCookie( + cookieHeader: string | null | undefined, +): Attribution { + if (!cookieHeader) return {}; + const prefix = `${ATTRIBUTION_COOKIE}=`; + const pair = cookieHeader + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)); + return parseAttribution(pair?.slice(prefix.length)); +} diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 338eca465..62fc35b90 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -1,3 +1,4 @@ +export * from "./attribution"; export * from "./events"; export * from "./server"; export * from "./utils"; diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts index d9901e353..f176b895a 100644 --- a/packages/api/src/edge.ts +++ b/packages/api/src/edge.ts @@ -21,6 +21,7 @@ import { privateLocationRouter } from "./router/privateLocation"; import { statusPageRouter } from "./router/statusPage"; import { statusReportRouter } from "./router/statusReport"; import { tinybirdRouter } from "./router/tinybird"; +import { trackRouter } from "./router/track"; import { userRouter } from "./router/user"; import { workspaceRouter } from "./router/workspace"; import { createTRPCRouter } from "./trpc"; @@ -46,6 +47,7 @@ export const edgeRouter = createTRPCRouter({ member: memberRouter, checker: checkerRouter, feedback: feedbackRouter, + track: trackRouter, statusPage: statusPageRouter, privateLocation: privateLocationRouter, import: importRouter, diff --git a/packages/api/src/router/track.ts b/packages/api/src/router/track.ts new file mode 100644 index 000000000..7e7b3de6f --- /dev/null +++ b/packages/api/src/router/track.ts @@ -0,0 +1,23 @@ +import { Events } from "@openstatus/analytics"; +import { z } from "zod"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +const stepStatus = z.enum(["completed", "skipped", "none"]); + +// Tracking-only procedures for UI moments with no mutation of their own. +// Server-side so the events carry user, workspace and plan like every other. +export const trackRouter = createTRPCRouter({ + onboardingSkip: protectedProcedure + .meta({ track: Events.SkipOnboarding, trackProps: ["step"] }) + .input(z.object({ step: z.enum(["monitor", "page"]) })) + .mutation(() => ({ ok: true })), + + onboardingComplete: protectedProcedure + .meta({ + track: Events.CompleteOnboarding, + trackProps: ["monitor", "page"], + }) + .input(z.object({ monitor: stepStatus, page: stepStatus })) + .mutation(() => ({ ok: true })), +}); -- 2.54.0