Spaces happy path: perf percentiles (phase span map)
Spaces happy path: perf percentiles (phase span map)
Status: PR 1 (feat/bri-337/perf-span-coverage, merged to beta) adds the
span/event coverage. This doc (PR 2) holds the HogQL recipes and the insight
checklist. All 10 insights are created in PostHog (tag
performance/spaces happy path) with short_ids recorded below; they return
empty until PR 1’s span coverage has ~24h of data on the events listed.
Linear: BRI-337.
Phase -> span/event map
Every phase emits a Phoenix span (when the work happens inline) and a PostHog
event ${name}.completed with duration_ms, both via the obs facade
(withSpan / with_span / event). Never raw OTel/PostHog calls. When comparing environments in an insight,
filter PostHog events on deployment.environment (beta vs prod; prod is
the default when DEPLOYMENT_ENV is unset).
| Phase | Span/event name | Where | Notes |
|---|---|---|---|
| space.create | space.create |
apps/web spaces POST route | wraps the create |
| compose.dispatch | compose.dispatch |
packages/dispatch sweep.ts dispatchJobBody |
dispatch.runpod nests inside |
| queue.wait | queue.wait.completed (event only) |
packages/jobs completion.ts | no span: the wait already happened; duration_ms = queueWaitS * 1000; Phoenix already has the runpod.queue_wait_s attribute on the completion server span |
| runner.generate | runner.generate |
runpod handler + worker main | inner span around SA3 wall time, inside runpod.handler / worker.process_job |
| runner.encode | runner.encode (runpod) / runner.encode_and_upload (home) |
both runners | split out of the former bundled encode_and_upload on runpod (handler.py:457); the home worker keeps one combined runner.encode_and_upload span because encode+upload run in a shared thread pool inside inference_core (worker main.py:387), so home has no separate runner.upload event |
| runner.upload | runner.upload |
both runners | split out of the former bundled encode_and_upload |
| delivery.url_signed | delivery.url_signed |
packages/jobs delivery.ts downloadFileUrl |
resign: true attribute on re-signs; insights filter resign != true |
Property conventions
All phase spans/events carry:
runner:"home"or"runpod"(which runner executed the job)slug: job slug (maps to Phoenix spansession.id)trace_id: present in PostHog event props whenctx.traceIdis set (via
obs-jscontextProperties); enables Phoenix deep links
phoenix://projects/brioso/traces/<trace_id>track_count: number of tracks in the jobduration_s: requested audio duration, where meaningfulduration_ms: always present on${name}.completedPostHog events
(emitted bywithSpan/with_spanon success)
Runner phase spans additionally carry audio.duration_s and track.count as
span attributes.
PostHog insights (PR 2)
Project: https://eu.posthog.com/project/219634 (insight URLs below follow the
/insights/<short_id> pattern used by the existing KPI docs). There is no
in-repo script that creates insights via the PostHog API today; the closest
tooling is posthog-cli (see .agents/notes/2026-08-14-posthog-cli.md), used
for manual UI/CLI work with --host https://eu.posthog.com. Insights are
therefore created in the PostHog UI (HogQL mode) and only their short_ids are
recorded here.
Recipe A: per-phase percentiles
One insight per phase event. HogQL query (replace the event name per phase):
SELECT
properties.runner AS runner,
quantiles(0.5, 0.75, 0.9, 0.95)(properties.duration_ms) AS p50_p75_p90_p95_ms
FROM events
WHERE event = 'runner.generate.completed'
AND timestamp > now() - interval 24 hour
GROUP BY runner
ORDER BY runner
Phase events to cover: space.create.completed, compose.dispatch.completed,
queue.wait.completed, runner.generate.completed,
runner.encode.completed (runpod only), runner.upload.completed (runpod
only), runner.encode_and_upload.completed (home only),
delivery.url_signed.completed.
Extra breakdowns (swap properties.runner for, or add as extra groups):
properties.track_count: does parallelism hurt encode/upload?properties.duration_s: does requested audio length drive generate time?
PostHog UI breakdowns cap at 25 values per breakdown in trends; using HogQL
GROUP BY in a SQL insight avoids the limit entirely, so prefer the SQL
insight type for these.
Recipe B: end-to-end runner time
The runner root span duration is the E2E runner time (generate + encode +
upload inside one process). runpod.handler.completed covers the RunPod path,
worker.process_job.completed the home path; combine both in one insight:
SELECT
properties.runner AS runner,
quantiles(0.5, 0.75, 0.9, 0.95)(properties.duration_ms) AS e2e_runner_ms
FROM events
WHERE event IN ('runpod.handler.completed', 'worker.process_job.completed')
AND timestamp > now() - interval 24 hour
GROUP BY runner
ORDER BY runner
Queue wait is the missing upstream leg (RunPod path only, per
completion.ts:286). Read it alongside the E2E number:
SELECT
quantiles(0.5, 0.75, 0.9, 0.95)(properties.duration_ms) AS queue_wait_ms
FROM events
WHERE event = 'queue.wait.completed'
AND timestamp > now() - interval 24 hour
True user-perceived E2E (space create through signed URL) would need a
cross-event sum per trace; the two insights above are the practical proxy:
space.create + compose.dispatch + queue.wait + runner root + delivery.
Recipe C: slow contributors top 10
One insight per slow phase (start with runner.generate.completed and
queue.wait.completed). SQL insight type:
SELECT
distinct_id,
properties.slug AS slug,
properties.trace_id AS trace_id,
properties.duration_ms AS duration_ms,
timestamp
FROM events
WHERE event = 'runner.generate.completed'
AND timestamp > now() - interval 24 hour
ORDER BY duration_ms DESC
LIMIT 10
Deep-link the worst offenders into Phoenix:
phoenix://projects/brioso/traces/<trace_id> (paste the trace_id into the
Phoenix trace search; the URL form is the convention used in the runbooks).
Note: delivery.url_signed.completed has no first-sign marker in PR 1 (every
sign fires, see tracks.ts:254), so for that phase either accept that repeat
downloads pollute the top-10 or filter duration_ms > 1000 to surface only
meaningfully slow signs.
Insight checklist
Folder: “Performance / Spaces happy path” (create it if missing). Create each
insight with the recipe above, name it as suggested, and record the short_id
here during execution.
| # | Insight name | Recipe | Event(s) | short_id |
|---|---|---|---|---|
| 1 | perf.space.create.percentiles | A | space.create.completed | cORE8P9d |
| 2 | perf.compose.dispatch.percentiles | A | compose.dispatch.completed | HEPH8xTl |
| 3 | perf.queue.wait.percentiles | A + B | queue.wait.completed | RnlSpS0e |
| 4 | perf.runner.generate.percentiles | A | runner.generate.completed | 65Zl30oa |
| 5 | perf.runner.encode.percentiles | A | runner.encode.completed + runner.encode_and_upload.completed | kDcpwCB9 |
| 6 | perf.runner.upload.percentiles | A | runner.upload.completed | JxALWFmv |
| 7 | perf.delivery.url_signed.percentiles | A | delivery.url_signed.completed | B2AwZkUj |
| 8 | perf.spaces.happy_path (E2E runner) | B | runpod.handler.completed, worker.process_job.completed | 7rYrJPDS |
| 9 | perf.slow_contributors.generate | C | runner.generate.completed | egAoKuxb |
| 10 | perf.slow_contributors.queue_wait | C | queue.wait.completed | DC8rOarD |
Reading the bottleneck
Compare each phase’s p95 against its p50: a wide gap means a tail problem
(capacity, cold start, one slow shard), a uniformly high p50 means a constant
cost (model, egress bandwidth).
- queue.wait high -> RunPod cold start / capacity (check whether p95 spikes
cluster at job bursts; cross-referencerunpod.handlercold starts) - runner.generate high -> model/engine (SA3 wall time; compare
audio.duration_svsduration_ms) - runner.encode high -> ffmpeg/encode workers (scales with
track_count) - runner.upload high -> R2 egress (scales with
track_count; on the home
runner readrunner.encode_and_uploadminusrunner.generateas the
combined encode+upload proxy) - space.create / compose.dispatch / delivery.url_signed should be
milliseconds; if not, the web app or R2 presign is the problem
AC deviations (record on BRI-337)
- “Aggregation dashboard loads in < 2s”: moot. There is no in-repo dashboard
page; the dashboard IS PostHog, so this AC collapses into PostHog’s own
insight load performance. - “Percentiles for 24h window within 5s”: reinterpret as PostHog insight load
time. Verify once (after the first 24h of PR 1 data) by timing each insight
in the checklist; if any exceed 5s, note it rather than optimizing. - Encode/upload split exists only on the RunPod runner (handler.py:457,466).
The home runner keeps a combinedrunner.encode_and_uploadspan
(worker main.py:387) because encode and upload run concurrently in a shared
thread pool inside inference_core, so insights 5 and 6 show home data only
as the combined proxy. queue.wait.completedfires only on the RunPod completion path
(completion.ts:286): the home runner records no claim latency.