Skip to content

Add query-count perf harness + instrumentation#653

Merged
ascorbic merged 5 commits into
mainfrom
query-count-harness
Apr 19, 2026
Merged

Add query-count perf harness + instrumentation#653
ascorbic merged 5 commits into
mainfrom
query-count-harness

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds an opt-in query-count harness that measures DB queries per route against a fixture site, with separate snapshots for SQLite (node adapter) and D1 (Cloudflare via wrangler). Meant for catching per-route query regressions in PRs.

Three pieces:

Instrumentation (emdash/database/instrumentation) — Kysely log hook that reads a recorder from the ALS request context and emits [emdash-query-log]-prefixed NDJSON on stdout. Gated behind EMDASH_QUERY_LOG=1; zero overhead when unset (the log option is not passed to Kysely at all). The same helper is reused by @emdash-cms/cloudflare's per-request D1 session Kysely via a new export from emdash.

Fixture (fixtures/perf-site) — minimal blog-style site based on templates/blog with a conditional astro.config.mjs that switches between node+sqlite and cloudflare+d1 via EMDASH_FIXTURE_TARGET.

Harness (scripts/query-counts.mjs, pnpm query-counts) — builds the fixture with astro build, serves it via the prod adapter entry (node dist/server/entry.mjs for SQLite, astro preview → wrangler for D1), fetches each route with cold/warm phase headers, and diffs aggregate counts against scripts/query-counts.snapshot.{sqlite,d1}.json. For D1 specifically, each route gets its own fresh astro preview process so every cold hit lands on a genuinely fresh workerd isolate.

--skip-seed and --skip-build compose for fast local iteration. A dist/.perf-target marker prevents reusing a build for the wrong target.

New query-counts job in .github/workflows/ci.yml runs both targets on every PR.

The fixture adds `kysely` as a direct dep because Astro's node adapter externalizes it at build time — scoped to the fixture only, doesn't touch the main integration.

Type of change

  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • `pnpm typecheck` passes
  • `pnpm lint` passes
  • `pnpm test` passes (or targeted tests for my change)
  • `pnpm format` has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (not applicable)
  • I have added a changeset
  • New features link to an approved Discussion (n/a — tooling, not a user-facing feature)

AI-generated code disclosure

  • This PR includes AI-generated code

Test plan

  • `pnpm query-counts` — sqlite snapshot, deterministic across runs
  • `pnpm query-counts --target d1` — d1 snapshot, deterministic across runs
  • `--skip-seed --skip-build` fast loop works for both targets
  • Cross-target `--skip-build` errors with a clear message instead of silently serving the wrong build
  • Core test suite still passes (2397 tests)

Opt-in Kysely log hook gated behind EMDASH_QUERY_LOG=1 emits per-request
NDJSON on stdout so a harness can count DB queries per route. Zero
overhead when disabled. Exposed at emdash/database/instrumentation so
@emdash-cms/cloudflare can wire the same hook into its per-request D1
session Kysely.

Adds fixtures/perf-site (minimal blog-style fixture, dual sqlite/d1
config), scripts/query-counts.mjs (pnpm query-counts), committed
snapshot files for both targets, and a CI job that runs both.
Copilot AI review requested due to automatic review settings April 18, 2026 21:52
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 18, 2026

🦋 Changeset detected

Latest commit: bc3b69a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 18, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground bc3b69a Apr 19 2026, 06:40 AM

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This PR changes 6,004 lines across 41 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 18, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@653

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@653

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@653

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@653

emdash

npm i https://pkg.pr.new/emdash@653

create-emdash

npm i https://pkg.pr.new/create-emdash@653

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@653

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@653

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@653

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@653

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@653

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@653

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@653

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@653

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@653

commit: bc3b69a

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in, snapshot-based “queries per route” performance harness backed by new Kysely query instrumentation and a dedicated fixture site, and wires the harness into CI to catch query-count regressions across SQLite (node adapter) and D1 (Cloudflare).

Changes:

  • Introduce core query instrumentation (kyselyLogOption, ALS recorder, stdout NDJSON flush) and wire it into core/adapter Kysely instances.
  • Add a new perf fixture Astro site (fixtures/perf-site) that can run on sqlite+node or d1+cloudflare.
  • Add the query-count harness + snapshots and a new CI job to run both targets on PRs.

Reviewed changes

Copilot reviewed 38 out of 41 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
scripts/query-counts.mjs New harness to build/serve fixture, hit routes cold/warm, aggregate query-log events, and diff/update snapshots.
scripts/query-counts.snapshot.sqlite.json Baseline expected query counts for sqlite target.
scripts/query-counts.snapshot.d1.json Baseline expected query counts for d1 target.
package.json Adds pnpm query-counts script entry.
.github/workflows/ci.yml Adds query-counts CI job to run harness for sqlite + d1.
.gitignore Ignores .perf-query-counts artifact.
pnpm-workspace.yaml Includes fixtures/* as workspace packages.
pnpm-lock.yaml Adds perf fixture importer deps; lockfile adjustments.
.changeset/query-counts-harness.md Changeset documenting new instrumentation + exports.
packages/core/src/database/instrumentation.ts New instrumentation module (recorder + flush + kyselyLogOption).
packages/core/package.json Exports ./database/instrumentation.
packages/core/tsdown.config.ts Ensures instrumentation is built/published from core.
packages/core/src/request-context.ts Adds queryRecorder to ALS request context type.
packages/core/src/astro/middleware.ts Attaches recorder per request when enabled and flushes at end of request.
packages/core/src/loader.ts Adds log: kyselyLogOption() to loader-scoped Kysely construction.
packages/core/src/emdash-runtime.ts Adds log: kyselyLogOption() to runtime-scoped Kysely construction.
packages/core/src/database/connection.ts Adds log: kyselyLogOption() to direct DB factory Kysely construction.
packages/cloudflare/src/db/d1.ts Wires kyselyLogOption() into request-scoped D1 session Kysely instance.
fixtures/perf-site/package.json New fixture package with deps/scripts for sqlite + d1 runs.
fixtures/perf-site/astro.config.mjs Conditional adapter/integration config via EMDASH_FIXTURE_TARGET.
fixtures/perf-site/wrangler.jsonc Wrangler config for fixture D1/R2 + enables query log var.
fixtures/perf-site/tsconfig.json Fixture TS config (node types).
fixtures/perf-site/src/worker.ts Cloudflare worker entrypoint for Astro.
fixtures/perf-site/src/live.config.ts Live collection config using emdashLoader().
fixtures/perf-site/src/layouts/Base.astro Base layout for fixture site; includes menus, search, theme styling.
fixtures/perf-site/src/styles/theme.css Theme override CSS for fixture site.
fixtures/perf-site/src/utils/reading-time.ts Reading-time utilities used by fixture pages.
fixtures/perf-site/src/utils/site-identity.ts Helper to resolve site identity from settings.
fixtures/perf-site/src/components/PostCard.astro Post card component for fixture pages.
fixtures/perf-site/src/components/TagList.astro Tag list component for fixture pages.
fixtures/perf-site/src/pages/index.astro Fixture homepage route (used by harness).
fixtures/perf-site/src/pages/posts/index.astro Fixture posts listing route (used by harness).
fixtures/perf-site/src/pages/posts/[slug].astro Fixture post detail route (used by harness).
fixtures/perf-site/src/pages/pages/[slug].astro Fixture page detail route (used by harness).
fixtures/perf-site/src/pages/category/[slug].astro Fixture category archive route (used by harness).
fixtures/perf-site/src/pages/tag/[slug].astro Fixture tag archive route (used by harness).
fixtures/perf-site/src/pages/search.astro Fixture search route (used by harness).
fixtures/perf-site/src/pages/rss.xml.ts Fixture RSS route (used by harness).
fixtures/perf-site/src/pages/404.astro Fixture 404 route.
fixtures/perf-site/seed/seed.json Fixture seed data used by harness setup.
fixtures/perf-site/emdash-env.d.ts Committed generated types for fixture collections.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/query-counts.mjs Outdated
Comment on lines +263 to +271
let resolveReady;
let rejectReady;
const ready = new Promise((res, rej) => {
resolveReady = res;
rejectReady = rej;
});
const readyTimer = setTimeout(() => {
rejectReady(new Error(`server did not become ready within 120s (regex ${readyRe})`));
}, 120_000);
Comment thread scripts/query-counts.mjs
Comment on lines +321 to +329
const r = await fetch(`${BASE}${path}`, {
method,
headers: { "x-perf-phase": phase },
redirect: "manual",
});
await r.arrayBuffer();
process.stdout.write(` ${phase.padEnd(5)} ${method} ${path} -> ${r.status}\n`);
return r.status;
} catch (err) {
Comment on lines +16 to +23
// Fetch tags for each post (bylines are already hydrated by getEmDashCollection)
const postsWithTags = await Promise.all(
sortedPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return { post, tags, bylines };
})
);
Comment on lines +3 to +44
tags: Array<{ slug: string; label: string }>;
class?: string;
}

const { tags, class: className } = Astro.props;
---

{tags.length > 0 && (
<ul class:list={["tag-list", className]}>
{tags.map((tag) => (
<li>
<a href={`/tag/${tag.slug}`} class="tag">{tag.label}</a>
</li>
))}
</ul>
)}

<style>
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
list-style: none;
padding: 0;
margin: 0;
}

.tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast);
}

.tag:hover {
color: var(--color-text);
background: var(--color-border);
}
<div class="nav-links">
{
menu?.items.map((item) => (
<a href={item.url} target={item.target}>
Comment on lines +39 to +49
// Fetch tags for grid posts (bylines are already hydrated by getEmDashCollection)
const gridPostsWithTags = await Promise.all(
gridPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return {
post,
tags: tags.map((t) => ({ slug: t.slug, label: t.label })),
bylines,
};
})
Comment on lines +19 to +25
// Fetch tags for display on each post card
const filteredPosts = await Promise.all(
posts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
return { post, tags };
})
);
Comment on lines +19 to +25
// Fetch tags for display on each post card
const filteredPosts = await Promise.all(
posts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
return { post, tags };
})
);
Comment on lines +51 to +55
/**
* Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.
* The Kysely `log` hook appends an event per query; middleware flushes
* to NDJSON after the response.
*/
Comment on lines +91 to +95
/**
* Returns a Kysely `log` option when instrumentation is enabled, or undefined.
* Pass as `new Kysely({ dialect, log: kyselyLogOption() })` so disabled mode
* has zero overhead — Kysely skips query timing entirely when `log` is absent.
*/
@github-actions
Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

pnpm exec emdash fails in CI because bin symlinks aren't linked for
workspace-local packages (see scripts/relink-bins-if-needed.mjs, which
early-exits under CI). Invoke the built CLI entry by absolute path
instead so the harness works in both CI and local dev.
The fixture config imports from @emdash-cms/cloudflare for the d1 path,
so `pnpm run --filter emdash... build` (which only walks emdash's
deps, not its dependents) leaves cloudflare unbuilt and astro fails
to resolve the import when loading the config.
The ready-regex approach was fragile — in CI, the cloudflare adapter's
dev mode wraps output in [vite] prefixes and the "ready in" line
sometimes never matches (observed on the D1 seed step: typegen POST
succeeded but ready timeout still fired).

TCP-connect is the real question anyway ("is the server accepting
connections?"). It also doesn't warm a fresh workerd isolate —
workerd defers isolate creation to the first HTTP request — so the
per-route cold-isolate measurement stays honest.
`astro dev` (the seed step) leaves .wrangler/deploy/ without the
build-time config.json that cloudflare adapter's preview requires, so
running `astro build` after the seed is what makes the subsequent
`astro preview` spins work.
@ascorbic ascorbic merged commit f97d6ab into main Apr 19, 2026
31 checks passed
@ascorbic ascorbic deleted the query-count-harness branch April 19, 2026 06:55
@emdashbot emdashbot Bot mentioned this pull request Apr 18, 2026
0aveRyan pushed a commit to 0aveRyan/emdash that referenced this pull request Apr 27, 2026
* feat: add query-count perf harness + instrumentation

Opt-in Kysely log hook gated behind EMDASH_QUERY_LOG=1 emits per-request
NDJSON on stdout so a harness can count DB queries per route. Zero
overhead when disabled. Exposed at emdash/database/instrumentation so
@emdash-cms/cloudflare can wire the same hook into its per-request D1
session Kysely.

Adds fixtures/perf-site (minimal blog-style fixture, dual sqlite/d1
config), scripts/query-counts.mjs (pnpm query-counts), committed
snapshot files for both targets, and a CI job that runs both.

* fix(perf): invoke emdash CLI directly in query-counts harness

pnpm exec emdash fails in CI because bin symlinks aren't linked for
workspace-local packages (see scripts/relink-bins-if-needed.mjs, which
early-exits under CI). Invoke the built CLI entry by absolute path
instead so the harness works in both CI and local dev.

* ci(perf): build all packages for query-counts job

The fixture config imports from @emdash-cms/cloudflare for the d1 path,
so `pnpm run --filter emdash... build` (which only walks emdash's
deps, not its dependents) leaves cloudflare unbuilt and astro fails
to resolve the import when loading the config.

* fix(perf): wait for TCP port instead of parsing stdout for ready

The ready-regex approach was fragile — in CI, the cloudflare adapter's
dev mode wraps output in [vite] prefixes and the "ready in" line
sometimes never matches (observed on the D1 seed step: typegen POST
succeeded but ready timeout still fired).

TCP-connect is the real question anyway ("is the server accepting
connections?"). It also doesn't warm a fresh workerd isolate —
workerd defers isolate creation to the first HTTP request — so the
per-route cold-isolate measurement stays honest.

* fix(perf): seed D1 before building for preview

`astro dev` (the seed step) leaves .wrangler/deploy/ without the
build-time config.json that cloudflare adapter's preview requires, so
running `astro build` after the seed is what makes the subsequent
`astro preview` spins work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants