Commit Graph

5 Commits

Author SHA1 Message Date
Matt Kane ca47da485d fix(core): capture streaming queries in query instrumentation (#1580)
The per-query log (EMDASH_QUERY_LOG=1) flushed its recorder when middleware
returned, i.e. when the response headers were ready but before the body
streamed. Queries issued by components during streaming were appended to the
recorder but never emitted, so the query-count harness only saw pre-header
queries. Astro 7's queued rendering moves more queries into the streaming
phase, which made the gap obvious (the post-detail fixture route reported 9
queries while the request actually ran 24).

Flush the recorder when the body finishes streaming instead (in the same
stream-end transform that already snapshots the metrics), and keep a fallback
flush in the middleware finally for bodyless responses (redirects, 304s). The
flush is now idempotent so the two paths can't double-emit.

Also adds a companion query-text snapshot to the harness
(query-counts.queries.{target}.json): a per-route map of the actual SQL to its
occurrence count, so a count change shows which query appeared or vanished, not
just that the number moved. Snapshots regenerated for both targets now reflect
full per-request query counts.
2026-06-22 14:38:26 +01:00
Matt Kane f9362d7a89 perf(core): emit stream-end metrics to expose post-header query cost (#1407)
* perf(core): emit stream-end db metrics after body streaming completes

Server-Timing db.* counters are snapshotted when middleware's next()
returns, but Astro streams the body afterwards and components issue
more DB queries that headers can never report (85-320ms of hidden
post-header query time measured in production).

When query instrumentation is enabled (EMDASH_QUERY_LOG=1), pipe the
rendered response body through an identity TransformStream and emit a
final [emdash-stream-end] NDJSON snapshot (db count/total/offsets,
cache hits/misses, total elapsed) in flush(), once the body finishes
streaming. The metrics object is mutated in-place by the Kysely log
hook, so the flush-time read observes every post-header query. The
wrapper forwards the astro.cookies symbol and drops Content-Length;
it is a no-op when instrumentation is off, the body is null, or no
request metrics are attached.

The query-counts harness now parses the new prefix and prints a
per-route stream-end report (informational only; snapshot files are
unchanged since timings are machine-dependent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: user-facing changeset wording

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:16:57 +01:00
Matt Kane cd2dcc6a56 perf: resolve byline avatar storage key during hydration (#1298)
* perf: resolve byline avatar storage key during hydration

Byline content-credit hydration now LEFT JOINs the media table so
entry.data.bylines carry avatarStorageKey/avatarAlt, letting list pages
build a direct avatar URL without a per-author MediaRepository.findById
(an N+1). The fields are additive/optional and null on the plain byline
finders. Inferred author bylines (findByUserIds) get the same join.

Also adds opt-in seed support for byline avatars (bylines[].avatar,
hermetic: no download) and a perf-fixture route plus query-count
snapshots measuring the optimized vs naive per-avatar-lookup paths.

* fix: harden seed avatar validation and cleanup per PR review

- deleteMediaRow is now best-effort (logs+swallows) so cleanup failure
  can't mask the original byline-write error (emdashbot)
- reject leading/trailing whitespace on avatar storageKey/filename/mimeType,
  which are used verbatim in the media lookup (Copilot)
- correct docstrings: storage keys aren't constrained to {ulid}{ext}, and
  the new BylineSummary fields are null (not absent) on plain finders (Copilot)
2026-06-02 20:12:39 +01:00
Matt Kane 03847341b0 ci: auto-update query-count snapshots instead of failing (#655)
* ci: auto-update query-count snapshots on PR instead of failing

Three workflows replace the single fail-on-drift job:

1. Query Counts (pull_request) — runs the harness, uploads the
   regenerated snapshots as an artifact if they drift. Always passes,
   runs with read-only permissions and PR-authored code.

2. Query Counts — Apply (workflow_run) — triggered when the measure
   workflow completes. Downloads the artifact, cross-verifies the PR
   number against the workflow_run's head SHA to guard against a
   tampered artifact, and pushes the regenerated snapshots back to the
   PR branch (same-repo directly, forks via GIT_ASKPASS). Never runs
   PR-authored code, so it can hold the app token safely.

3. Query Counts — Label (pull_request_target, paths-filtered) —
   applies the "query-count changed" label when a PR diff touches
   either snapshot file (either because the author changed them, or
   because workflow 2 just auto-pushed).

* ci(query-counts): fail if fork push fails

If the bot can't push snapshots to a fork PR (most likely because the
contributor has 'Allow edits by maintainers' disabled), fail the Apply
workflow loudly instead of silently succeeding. That way the PR shows
a red check and the reviewer knows to either ask the contributor to
enable maintainer edits or run the harness themselves.

* ci(query-counts): fix artifact upload and eliminate false-positive drift

Two issues in the first run on main:

1. actions/upload-artifact treats dirs starting with a dot as hidden and
   skips them when include-hidden-files is false (the default), so the
   staging dir .query-counts-out/ uploaded zero files and failed the
   step. Renamed to query-counts-out/ (and gitignored).

2. The harness wrote snapshots with JSON.stringify's default 2-space
   indent. oxfmt then reformatted the committed files to tabs (per the
   repo's prettier config), which produced a permanent whitespace diff
   every time CI regenerated the files. The drift check interpreted
   that as a real count change. Switched the harness to tab indent so
   its output matches the formatted file verbatim.

* ci(query-counts): address Copilot review

- Add actions:read to the Apply workflow's permissions so the API
  calls to list and download artifacts are authorised. With explicit
  permissions, everything not named becomes none.

- Check out the measured SHA from workflow_run.head_sha instead of
  the branch ref. If the PR branch has advanced between measure and
  apply, push HEAD:ref would fail non-fast-forward instead of
  silently applying stale snapshots to a newer tree. The next PR
  event kicks off a fresh cycle against the new head.

- Keep client-id (not app-id) for create-github-app-token — app-id
  is deprecated in v3.1.1 in favour of client-id per the action's
  own action.yml. Other workflows in the repo will migrate at their
  next touch.
2026-04-19 08:44:21 +01:00
Matt Kane f97d6ab0f1 Add query-count perf harness + instrumentation (#653)
* 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.
2026-04-19 07:55:41 +01:00