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
1 change: 1 addition & 0 deletions apps/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"generate:og": "node scripts/generate-og.mjs",
"screenshots": "bun scripts/screenshots/capture.ts",
"screenshots:thread": "bun scripts/screenshots/capture.ts --set thread",
"dev": "bun run --silent sync:cli && exec env -u CLAUDECODE astro dev --port ${PORT:-3391} --host ${HOST:-127.0.0.1}",
"sync:i18n": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide --strategy url baseLocale && astro sync",
"sync:cli": "mkdir -p public/cli && cp ../../scripts/install.sh public/cli/install && cp ../../scripts/uninstall.sh public/cli/uninstall",
Expand Down
66 changes: 51 additions & 15 deletions apps/landing/scripts/screenshots/capture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
/**
* `bun run screenshots`: capture the landing site's app screenshots from the
* local stack. Seed it first with `bun run seed:demo` at the repo root; the
* local stack (`--set thread` for the feature-thread PNGs). Seed it first with `bun run seed:demo` at the repo root; the
* time ranges come from that run's anchor.
*
* Signs in with a one-shot Clerk ticket (dev instances only, like
Expand All @@ -14,10 +14,32 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"
import { ChildProcess, ChildProcessSpawner } from "effect/process"
import { chromium, type BrowserContext, type Page } from "playwright"
import { RELABEL, SHOTS, TIMEZONE, VIEWPORT, type Shot } from "./shots"
import { THREAD_RELABEL, THREAD_SHOTS } from "./thread-shots"

const ROOT = new URL("../../../../", import.meta.url).pathname
const SEED_STATE = `${ROOT}scripts/seed-demo/.last-seed.json`
const OUT_DIR = new URL("../../public/screenshots/", import.meta.url).pathname

interface ShotSet {
readonly shots: ReadonlyArray<Shot>
readonly relabel: Readonly<Record<string, string>>
readonly outDir: string
readonly format: "webp" | "png"
}

const SETS = {
landing: {
shots: SHOTS,
relabel: RELABEL,
outDir: new URL("../../public/screenshots/", import.meta.url).pathname,
format: "webp",
},
thread: {
shots: THREAD_SHOTS,
relabel: THREAD_RELABEL,
outDir: new URL("../../../../feature-thread/", import.meta.url).pathname,
format: "png",
},
} satisfies Record<string, ShotSet>
const DEV_EMAIL = "david+clerk_test@gmail.com"
const MINUTE = 60_000

Expand Down Expand Up @@ -88,7 +110,7 @@ const FREEZE_CSS = `
[data-sonner-toaster], #react-scan-root, .tsqd-parent-container { display: none !important; }
`

const relabel = (page: Page) =>
const relabel = (page: Page, labels: ShotSet["relabel"]) =>
pw("relabel", () =>
page.evaluate((pairs) => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
Expand All @@ -97,7 +119,7 @@ const relabel = (page: Page) =>
if (node.nodeValue?.includes(from)) node.nodeValue = node.nodeValue.replaceAll(from, to)
}
}
}, Object.entries(RELABEL)),
}, Object.entries(labels)),
)

/** Network quiet and no skeletons left: the page is showing data, not a loading state. */
Expand Down Expand Up @@ -140,13 +162,18 @@ const capture = Effect.fn("capture.shot")(function* (
base: string,
shot: Shot,
anchor: number,
set: ShotSet,
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const fs = yield* FileSystem.FileSystem
const page = yield* Effect.acquireRelease(
pw("open page", () => context.newPage(), shot.id),
(page) => Effect.promise(() => page.close()),
)
const fakeNow = shot.fakeNowMinutes
if (fakeNow !== undefined) {
yield* pw("clock", () => page.clock.setFixedTime(new Date(anchor + fakeNow * MINUTE)), shot.id)
}
yield* pw("navigate", () => page.goto(shotUrl(base, shot, anchor)), shot.id)
yield* pw("freeze", () => page.addStyleTag({ content: FREEZE_CSS }), shot.id)
yield* settle(page, shot.id)
Expand All @@ -155,18 +182,20 @@ const capture = Effect.fn("capture.shot")(function* (
yield* pw("setup", () => setup(page), shot.id)
yield* settle(page, shot.id)
}
yield* relabel(page)
yield* relabel(page, set.relabel)

const png = yield* fs.makeTempFileScoped({ suffix: ".png" })
yield* pw("screenshot", () => page.screenshot({ path: png }), shot.id)
const out = `${OUT_DIR}${shot.id}.webp`
yield* spawner
.string(ChildProcess.make("cwebp", ["-quiet", "-q", "90", png, "-o", out]))
.pipe(
Effect.mapError(
(cause) => new CaptureError({ message: `cwebp: ${cause.message}`, shot: shot.id }),
),
)
const out = `${set.outDir}${shot.id}.${set.format}`
yield* (
set.format === "png"
? fs.copyFile(png, out)
: spawner.string(ChildProcess.make("cwebp", ["-quiet", "-q", "90", png, "-o", out]))
).pipe(
Effect.mapError(
(cause) => new CaptureError({ message: `write ${out}: ${cause.message}`, shot: shot.id }),
),
)
yield* Console.log(` ✓ ${shot.id} ${out.replace(ROOT, "")}`)
}, Effect.scoped)

Expand All @@ -175,6 +204,10 @@ const capture = Effect.fn("capture.shot")(function* (
const command = Command.make(
"capture-screenshots",
{
set: Flag.Literals("set", ["landing", "thread"]).pipe(
Flag.withDescription("Which shot list to capture"),
Flag.withDefault("landing"),
),
only: Flag.String("only").pipe(Flag.withDescription("Comma-separated shot ids"), Flag.optional),
web: Flag.String("web").pipe(
Flag.withDescription("Local web origin"),
Expand All @@ -198,8 +231,9 @@ const command = Command.make(
),
)
const anchor = DateTime.toEpochMillis(state.anchor)
const set: ShotSet = SETS[flags.set]
const wanted = Option.map(flags.only, (only) => new Set(only.split(",").map((id) => id.trim())))
const shots = SHOTS.filter((shot) =>
const shots = set.shots.filter((shot) =>
Option.match(wanted, { onNone: () => true, onSome: (ids) => ids.has(shot.id) }),
)
if (shots.length === 0) return yield* new CaptureError({ message: "no shots match --only" })
Expand Down Expand Up @@ -234,7 +268,9 @@ const command = Command.make(
await signIn.close()
})

yield* Effect.forEach(shots, (shot) => capture(context, flags.web, shot, anchor), { discard: true })
yield* Effect.forEach(shots, (shot) => capture(context, flags.web, shot, anchor, set), {
discard: true,
})
}, Effect.scoped),
).pipe(Command.withDescription("Capture landing screenshots from the seeded local stack"))

Expand Down
2 changes: 2 additions & 0 deletions apps/landing/scripts/screenshots/shots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface Shot {
readonly range?: { readonly fromMinutes: number; readonly toMinutes: number }
/** Clicks that reach the framed state once the page has data. */
readonly setup?: (page: Page) => Promise<void>
/** Minutes after the seed anchor to pin the page clock at, for pages that read "now". */
readonly fakeNowMinutes?: number
}

/** Dev-account labels swapped for the demo's before every capture. */
Expand Down
133 changes: 133 additions & 0 deletions apps/landing/scripts/screenshots/thread-shots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/** Screenshots for the feature thread. Same seeded stack and anchor as `shots.ts`. */
import type { Page } from "playwright"
import { RELABEL, type Shot } from "./shots"

export const THREAD_RELABEL = { ...RELABEL, "Verdict Lab": "Investigation" } as const

const zoomIn = async (page: Page, times: number) => {
const button = page.locator(".react-flow__controls-zoomin").first()
if ((await button.count()) === 0) return
for (let i = 0; i < times; i++) await button.click()
await page.waitForTimeout(400)
}

const hide = (page: Page, selectors: ReadonlyArray<string>) =>
page.evaluate((list) => {
for (const selector of list) {
for (const el of document.querySelectorAll<HTMLElement>(selector)) el.style.display = "none"
}
}, selectors)

const hideByText = (page: Page, texts: ReadonlyArray<string>) =>
page.evaluate((list) => {
for (const el of document.querySelectorAll<HTMLElement>("button, span, p, div")) {
if (el.children.length === 0 && list.includes(el.textContent?.trim() ?? "")) {
const target = el.closest("button") ?? el
target.style.visibility = "hidden"
}
}
}, texts)

const lastMinutes = (minutes: number) => ({ fromMinutes: minutes, toMinutes: 0 })
const FAILED_CHECKOUTS = { spanNames: ["POST /api/checkout"], hasError: true }

const followLink = async (page: Page, href: string | null, drop: ReadonlyArray<string> = []) => {
if (href === null) throw new Error("no link to follow")
const url = new URL(href, page.url())
for (const key of drop) url.searchParams.delete(key)
url.searchParams.set("quota_preview", "1")
await page.goto(url.toString())
}

const openTrace = async (page: Page) => {
const href = await page.locator('a[aria-label^="Open trace"]').first().getAttribute("href")
await followLink(page, href, ["peek", "peekT"])
}

export const THREAD_SHOTS: ReadonlyArray<Shot> = [
{ id: "01-service-map", route: "/service-map", range: lastMinutes(60), setup: (page) => zoomIn(page, 1) },
{
id: "02-traces-peek",
route: "/traces",
search: FAILED_CHECKOUTS,
range: lastMinutes(90),
setup: async (page) => {
await page.locator("tr[data-index]").first().click()
await page.waitForURL(/[?&]peek=/)
},
},
{
id: "03-trace-waterfall",
route: "/traces",
search: FAILED_CHECKOUTS,
range: lastMinutes(90),
setup: async (page) => {
await openTrace(page)
await page
.getByRole("tab", { name: /waterfall/i })
.first()
.click()
},
},
{
id: "04-trace-flow",
route: "/traces",
search: FAILED_CHECKOUTS,
range: lastMinutes(90),
setup: async (page) => {
await openTrace(page)
await page.getByRole("tab", { name: /flow/i }).first().click()
await page.waitForTimeout(800)
await zoomIn(page, 3)
},
},
{ id: "05-logs", route: "/logs", search: { severities: ["ERROR"] }, range: lastMinutes(90) },
{
id: "06-metric-pool",
route: "/metrics/db.client.connection.pending_requests",
range: lastMinutes(6 * 60),
},
{ id: "07-service-detail", route: "/services/payment-svc", range: lastMinutes(6 * 60) },
{ id: "08-errors", route: "/errors", fakeNowMinutes: 180 },
{
id: "09-issue-detail",
route: "/errors",
fakeNowMinutes: 180,
setup: async (page) => {
const row = page.locator('a[href*="/errors/issues/"]', { hasText: "ConnectionTimeout" }).first()
await followLink(page, await row.getAttribute("href"))
},
},
{
id: "10-alert-create",
route: "/alerts/create",
search: { serviceName: "payment-svc", template: "high_error_rate" },
fakeNowMinutes: 180,
// The breach count reads the server's clock, which the pinned page clock can't move.
setup: (page) => hideByText(page, ["No breaches in this window"]),
},
{ id: "11-releases", route: "/releases", range: lastMinutes(24 * 60) },
{
id: "12-dashboard-templates",
route: "/dashboards/templates",
range: lastMinutes(6 * 60),
search: { template: "service-health" },
},
{
id: "13-investigation",
route: "/lab/verdict",
setup: (page) => hide(page, ["main section > header", "main section ~ section"]),
},
{
id: "14-agent-session",
route: "/lab/agent-session",
setup: (page) => hideByText(page, ["Clean", "Capture off"]),
},
{ id: "15-mcp", route: "/mcp" },
{
id: "17-infra",
route: "/lab/infra",
setup: (page) => hideByText(page, ["/infra overview", "/INFRA OVERVIEW"]),
},
{ id: "18-service-map-3d", route: "/service-map", search: { view: "3d" }, range: lastMinutes(60) },
]
85 changes: 85 additions & 0 deletions feature-thread/THREAD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
## 1
Most people have no idea how much Maple does now.

Traces, logs, metrics, errors, alerts, releases, dashboards, an agent that investigates incidents, LLM agent observability, a 75+ tool MCP server, infra monitoring. All on OpenTelemetry.

Here's all of it 🧵
[t01-service-map-3d.png]

## 2
Traces. Every request across every service, faceted and searchable. Peek opens one without leaving the list, then waterfall, timeline and flow views. Every span links straight to its logs.
[t02a-traces-peek.png, t02b-trace-waterfall.png]

## 3
Logs. Search text and attributes together, filter by trace id, and see volume by severity at a glance. From any line you can jump to the span that was running when it was written.
[t03-logs.png]

## 4
Metrics. Every OTel metric is typed and listed, with per-series, reset-aware rate and increase. One click to add a chart to a dashboard or turn it into an alert.

Here: a DB connection pool backing up the minute a bad deploy landed.
[t04-metrics.png]

## 5
Services. p50/p95/p99, throughput, Apdex and error rate per service, with deploys marked on every chart.

The service map is built from real calls, with rate, errors and latency on every edge. Databases and caches show up too. (And yes, there's a 3D mode.)
[t05a-service-detail.png, t05b-service-map.png]

## 6
Errors. Exceptions are fingerprinted into issues, ignoring ids, emails and hosts. Claim, assign, set severity, comment. Resolved issues reopen if they come back.

Attach the PR that fixes one and Maple watches prod after merge to confirm it's gone.
[t06a-errors.png, t06b-issue-detail.png]

## 7
Alerts. Error rate, latency, Apdex, throughput, no-data, or any query or raw SQL.

Every rule previews against real history before you save it, so you see exactly when it would have fired. Slack, Discord, Telegram, PagerDuty, email, webhooks.
[t07-alert-preview.png]

## 8
Releases. Every deploy is compared with the version it replaced, across every service it reached.

This one took payment-svc from 0% to 10.5% errors and p95 from 389ms to 5s. You'd know within minutes, not when a customer emails.
[t08-releases.png]

## 9
Dashboards. 22 templates, and Maple tells you which ones your data can actually fill before you create anything. Variables, raw SQL widgets, drill-down from any chart into its traces, public share links and embeds.
[t09-dashboard-templates.png]

## 10
Investigations. Hit Investigate on an issue or incident and an agent reads your traces, logs, metrics and your code (in a sandboxed checkout), then hands back the cause, its confidence and what to do next.
[t10-investigation.png]

## 11
MCP. 75+ tools for Claude Code, Cursor, Windsurf or anything that speaks MCP. Search traces, diagnose a service, compare periods, run SQL, read your source, build dashboards and alerts.

One command to connect. OAuth, scoped to your org.
[t11-mcp.png]

## 12
Agent sessions. Shipping LLM agents? Maple turns GenAI traces into sessions: turns, tool calls, tokens, cost per model, and what went wrong (context window blowups, rate limits, failing tools).

Works with Claude Code and 20+ frameworks.
[t12-agent-session.png]

## 13
Infrastructure. Hosts, Docker, Kubernetes, Railway, Cloudflare and PlanetScale in one place, sorted by what needs a look first. Any Prometheus /metrics endpoint works too.
[t13-infra.png]

## 14
More:

• Session replay, linked to traces
• Web analytics, incl. AI crawler reads
• Product events and funnels
• A PR review agent grounded in prod telemetry
• Slack and Discord bot
• Maple Local: all of it as one binary
• Bring your own ClickHouse, EU region

## 15
Maple is source-available and built on OpenTelemetry, so if you already emit OTel you're one endpoint change away.

maple.dev
Binary file added feature-thread/t01-service-map-3d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t02a-traces-peek.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t02b-trace-waterfall.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t03-logs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t04-metrics.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t05a-service-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t05b-service-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t06a-errors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t06b-issue-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t07-alert-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t08-releases.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t09-dashboard-templates.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t10-investigation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t11-mcp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t12-agent-session.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added feature-thread/t13-infra.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading