Analytics read model and dashboard¶
Everything that reads the analytics database back: the connection-injectable query layer the page is built on, the page composition itself, and the labeled banner every empty or misconfigured case surfaces as instead of an exception. Both are observation-only and independent of the polling tick — the dashboard opens no GitHub session, writes no Postgres row, and can be pointed at a managed endpoint without touching the orchestrator's deployment.
The tables these reads scan, the sync that fills them, and the operator cron around it are on
analytics-database.md. Where the read and render paths sit in the package tree, and what
each package there is responsible for, is in
architecture/observability-modules.md; the knobs are in
configuration/observability.md. The Streamlit page itself sits beside the
package tree, at orchestrator/apps/analytics_dashboard.py.
Read model (orchestrator/observability/analytics/query/)¶
Thin, testable data-access layer over analytics_events, the analytics_agent_runs view, and the
analytics_daily_rollup materialized view. The dashboard's window-bounded aggregates read from the rollup; per-row
drill-downs and widgets the rollup cannot reconstruct exactly stay on the base table or the agent-run view. Nothing
here imports Streamlit, so the read path can be wired into any UI.
The raw, rollup, breakdown, and skill reads are owned by raw_reads.py, rollup_reads.py, breakdown_reads.py, and
skill_reads.py, with one projection owner per read beside them and the frozen result models every family returns by
the five result-family owners there. Each caller names the owner its read is defined on: the three skill-panel
adapters in observability/dashboard/skills.py reach skill_reads.py directly, the six comparison-panel adapters in
observability/dashboard/breakdowns.py reach rollup_reads.py and breakdown_reads.py the same way, and the seven
headline and lifecycle adapters in observability/dashboard/rollups.py reach those two plus raw_reads.py and the
SORT_BY_COST spelling issue_summaries.py declares.
The shared call boundary is a ReadRequest composed of ReadFilters, ReadConnection, and ReadOptions, declared by
observability/analytics/query/request_models.py. Its sibling requests.py binds every historical keyword signature
into that typed request before the family leaf executes, so existing calls and error behavior are unchanged while
implementation helpers no longer thread large argument lists. Every family leaf reaches execution.py's ReadQuery
for the connection inputs one read carries. Both connection paths resolve a caller's omitted db_url= through
observability/analytics/config.py's resolve_db_url, so the URL-source policy has one home.
Input owners. filters.py owns WindowFilters — the selection a read narrows by, plus the three scoped
projections (without_events for a view with no event column, catalog_scope for repo-level catalog rows,
historical_scope for a session's evidence from before the window) — and the builder that accumulates a clause and its
bindings together. predicates.py owns the one WHERE builder behind all three scan targets, so the events table, the
agent-run view, and the daily rollup read a filter the same way. conditions.py owns the two splices that add a
table's own required condition to either end of a generated clause (which fixes whether its operand binds first or
last) and agent_event_excluded, the probe view-backed readers short-circuit on.
Connection owners. connections.py decides what a read dials with — the deferred psycopg import, the per-query and
persistent connect factories over it, AnalyticsReadError, and the two judgments a caller makes about a socket rather
than a query (whether a close failed, whether an escaped error means the socket is gone). connection_cache.py decides
how long a socket lives: the thread-local entry, the URL it is keyed on, the two evictions, and the
analytics_connection / close_thread_local_connection pair over them. execution.py decides whose connection a
SELECT runs on, and closes only the descriptor it opened itself.
Result owners. One module per result family, each a plain frozen-dataclass module that reaches nothing — importing
a row never costs a connection factory, the configuration behind an omitted db_url=, or the driver behind those.
activity_models.py owns the time-bucketed cells (BackendDailyTokensRow, HourlyHeatmapPoint, ThroughputDayRow);
overview_models.py what a page frames a window with — FilterOptions, DataExtent, and Summary construct bare so
an unset ANALYTICS_DB_URL still renders a page, while TimeSeriesPoint requires the (day, event, count) key its
cost and token aggregates hang off; cost_models.py the spend breakdowns
(ReviewRoundBucketRow, BackendEfficiencyRow, RepoBreakdownRow, CostCoverageRow); run_models.py the run,
issue, and traced-event rows (StageBreakdown, EventBreakdown, AgentExitRow, IssueSummaryRow, IssueEventRow)
plus public_event_result, the accessor installed as the trace row's result property over its stored event_result
column; and skill_models.py the skill cells (SkillTriggerRateRow, SkillTriggerMatrixRow, SkillAdoptionRow) with
the zero-denominator-guarded share each derives.
Raw-read owners. raw_reads.py owns the six reads that stay on analytics_events rather than the day-bucketed
rollup above it — get_filter_options, get_data_extent, get_event_breakdown, get_recent_agent_exits,
get_issues, and get_issue_events. It also decides the answers that need no database: an unconfigured URL with no
caller-owned conn=, a non-positive limit, and a cleared multiselect each return the empty result without dialing,
while an unknown sort_by raises before the connect. Each read hands its filtered window to a projection owner:
filter_options.py (the tagged union behind the five dropdowns, plus the in-Python bucketing and sort it is read back
through), event_breakdowns.py (the per-event count), agent_exits.py (the pinned event = 'agent_exit' spliced
ahead of the generated predicate, so its operand binds first and the LIMIT last), issue_summaries.py (the
per-(repo, issue) aggregate scan, SORT_BY_LAST_SEEN / SORT_BY_COST, and the SQL ordering each becomes), and
issue_events.py (one issue's trace, ORDER BY ts ASC, id ASC). Beneath them, query_rows.py names the columns of
the four SELECT lists read back by field rather than by index — recent exits, issue summaries, one issue's trace,
review-round buckets — and the trace's named row is its SELECT list, so the two cannot disagree about where a column
sits. All but the first pad a row shorter than the list to the full width, which is what lets an older, narrower
fixture round-trip with its missing columns unset; the recent-exit row unpacks strictly, so a row short of its fifteen
columns raises. raw_values.py owns the NULL-preserving scalar coercions, the decode of a JSONB cell a driver hands
back either adapted or as text, plus the probe for a cleared multiselect.
Rollup-read owners. rollup_reads.py owns the seven reads that scan analytics_daily_rollup instead —
get_summary, get_kpi_prev, get_time_series, get_stage_breakdown, get_backend_efficiency,
get_repo_breakdown, and get_throughput_breakdown. A window bounded by whole days is what they have in common, and
what lets them read the rollup rather than a row's own ts. It decides the same no-database answers the raw hub does,
plus the agent-run event-filter short-circuit get_backend_efficiency returns on. One projection owner sits under
each read: summary_queries.py (the WITH win AS (...) CTE and its three UNION ALL branches) with
summary_results.py beside it (the in-Python count DESC, label ASC ranking, and the cast list that leaves a short
totals row's trailing fields at their model defaults), kpi_totals.py (the trimmed previous-window scalars),
time_series.py (the per-(day, event) cell), stage_breakdowns.py, backend_efficiency.py, repo_breakdowns.py,
and throughput_days.py (the done / rejected terminals, the stage-selection intersection, and the two
short-circuits that leave nothing to count).
Breakdown-read owners. breakdown_reads.py owns the remaining four, the ones whose grouping key the rollup threw
away — get_review_round_breakdown, get_cost_coverage, get_backend_daily_tokens, and get_hourly_heatmap. The
first three read per-run facts a day bucket aggregated over, so they scan analytics_agent_runs; the fourth needs the
hour that bucket rounded off, so it stays on analytics_events. It decides the same unconfigured-database answer the
other two hubs do, and the agent-run event-filter short-circuit applies to the three view-backed reads only — the
heatmap's scan has an event column, so the selection becomes an ordinary bound predicate there instead. One
projection owner sits under each: review_rounds.py (the bucket labelling, the two roles, and each role's cache /
no-cache split), cost_coverage.py (the per-source rollup that keeps unknown-price distinct from unknown),
backend_tokens.py (the per-(day, backend) token cell), and hourly_heatmaps.py (the UTC normalization and the
bound offset).
Skill-read owners. skill_reads.py owns the last three — get_skill_trigger_rates, get_skill_trigger_matrix,
and get_skill_adoption. Their fact is not a column: a skill name, a repository's offered set, and one run's load
count ride in an agent_exit row's extras JSONB, which neither the rollup nor the agent-run view carries. All three
therefore scan analytics_events and pin conditions.py's AGENT_EXIT_CONDITION themselves, so an events selection
that excludes agent_exit returns without dialing rather than running a query whose two conditions contradict; the
two capped reads pass a non-positive limit through as "every cell". One aggregate owner sits under each:
skill_trigger_rates.py (the whole-cohort denominator, the key-presence probe, and the summed trigger count),
skill_matrices.py (the window-scoped runs scan, the zero-padding over it, and the ranking the cap keeps the top
of), and skill_adoption.py (the per-session ratio and the invocation / load / incidental diagnostics beside it).
Beneath both of the last two, skill_provenance.py owns the repository-scoped catalog scan the padding is drawn
from, the narrower filtering that repo-level record takes, and the level lookup over it — an unclassified name is
filed at the level that repository offers it at, where it offers exactly one, and a name it never offered or offers
at two levels stays unknown; repo_skill_provenance / SkillProvenance are what both aggregates resolve through,
and SkillProvenance.resolve_row is the one step each takes over a name array and the level map beside it. Beneath
the adoption aggregate, skill_sessions.py owns the resume-then-session-then-row-id session key and the two session
scans' scopes — the window one picks which sessions count, the history one drops the start bound and the stage filter
while keeping the end bound — and applies the fill to every category either scan gathers: the window loads, the
incidental references, and a history row's offered set and loads alike. Beneath both aggregates, skill_values.py
owns the two JSONB coercions (the name array and the name-to-source-level map), the pairing that reads each name's
level off the same row and defaults an uncovered name to unknown, the (repo, role, backend) cohort with its
"unknown" bucketing, and the (repo, agent_role, backend, skill, level) cell both aggregates accumulate under.
Beneath the rollup and breakdown families, cache_shares.py owns the token-share SQL the cache / no-cache split is
weighted by — spelled once for the rollup's total_* sums and once for the agent-run view's per-run columns — and
row_cells.py the readings a cell from any family passes through: a positional read with a default for a row
narrower than the SELECT list, a nullable cost column read as a float, and a driver-widened day narrowed back to a
date. The NULL-preserving float coercion the stage and backend projections share is raw_values.py's, so both sides
of the read path narrow a nullable duration the same way.
get_summary(rollup) — date-bounded totals + per-event / per-stage breakdowns + token / cost sums, plustotal_agent_runs/failed_agent_runs/timed_out_agent_runsscoped toevent='agent_exit'.distinct_issuesisCOUNT(DISTINCT (repo, issue)). Single round-trip viaWITH win AS (...)CTE with threeUNION ALLbranches tagged by akinddiscriminator.get_kpi_prev(rollup) — stripped variant ofget_summaryreturning only the cost / token / agent-run scalars the dashboard reads offprev_summaryfor KPI deltas. Skips theCOUNT(DISTINCT)s andGROUP BYfollow-ups; ~one aggregate scan instead of three.get_time_series(rollup) — daily(day, event, count)rollups with per-cell cost / input / output / cache_read / cache_write token aggregates.get_stage_breakdown(rollup) — per-stage counts + weightedAVG(duration_s)recovered asSUM(duration_s_sum) / NULLIF(SUM(duration_s_count), 0), rolled-up cost / token totals, and arunsagent-exit subset count. The total cost is further split into cache vs no-cache (cache_cost_usd+no_cache_cost_usd); each rollup row'stotal_cost_usdis weighted by(total_cached_tokens + total_cache_read_tokens + total_cache_write_tokens) / (total_input_tokens + total_output_tokens + total_cache_read_tokens + total_cache_write_tokens)into the cache stack and the complement into no-cache.total_cached_tokensis the Codex "portion of input served from cache" counter and is already insidetotal_input_tokens, so it stays out of the denominator to avoid double-counting. Token-less rollup rows attribute their full cost to no-cache.get_repo_breakdown(rollup) — per-reporollup of issues / events / agent-exits / cost.get_backend_efficiency(rollup) — per-backend runs / failed / avg duration / cost / token totals with NULL backends surfaced as"unknown".event = 'agent_exit'is pinned in the WHERE clause.get_throughput_breakdown(rollup) — daily resolved / rejected counts overstage_enterrows whosestageisdoneorrejected. Short-circuits when the events multiselect excludesstage_enteror the stages selection excludes both terminals.get_filter_options(base table) — distinct repos / events / stages / backends / agent_roles for dropdowns. All five columns pulled in a singleUNION'd round-trip with rows tagged by their column.get_data_extent(base table) — min / maxtsso the sidebar date picker defaults to a window that contains rows.get_event_breakdown(base table) — per-event counts (the rollup pre-aggregates more finely thaneventalone, so the base-table read is cheaper here).get_recent_agent_exits(base table) — newest rows filtered toevent='agent_exit'.get_skill_trigger_rates(base table) — per-(agent_role, backend)skill-trigger aggregate:runs,skill_runs(rows whoseextrascarries askills_triggeredkey), andtotal_triggers(SUMofskills_triggered_count), with a derivedrateproperty. Reads the base table because the skill fields live inextras JSONB, which the rollup does not carry — no DDL.event = 'agent_exit'is pinned and the agent-exit event-filter short-circuit applies. NULLagent_role/backendbucket under"unknown". A0rate is a real "no trigger observed" signal but cannot tell a tracked-but-quiet run from one whoseTRACK_SKILL_TRIGGERSwas off.get_skill_trigger_matrix(base table) — per-(skill, level)×(repo, agent_role, backend)trigger-run matrix. Two base-table reads combined in Python: therepo_skill_catalogrecords (theskills_availableuniverse a repo offers; date/repo-filtered only, since those records are repo-level withissue = 0/ NULL stage) and the filteredagent_exitrows (each run'sskills_triggeredlist). Both scans read the recordedskill_levelsmap beside the names, so a name defined at two levels is two cells rather than one blended average: a catalog name the record left unclassified pads atproject(that scan enumerates a repository's own checked-in definitions), while an unclassified run name — a record written before levels existed, or a claude run whose stream names no source directory — is filed at the level that repository's catalog offers the name at whenever it offers exactly one, so the load merges into the padded cell instead of splitting off anunknownone beside it; a name the catalog never offered, or one it offers at two levels, keepsunknown, and a level the run itself recorded is never overwritten (skill_provenance.py). Each cell carriesskill_runs(runs containing the skill, one per run per distinct name — not total invocations) andruns(the total agent-exit runs in the cell's cohort, so a low/zero trigger count reads against the cohort size). Every catalog skill is zero-padded across the cohorts observed for its repo so the matrix carries explicit "offered but never triggered" cells (e.g.developer / claude / review,skill_runs = 0); with the catalog missing it degrades to just the observed-trigger cells. decomposer / question cohorts get the same catalog-backed zero rows as developer / reviewer whenever they have agent-exit runs. Rows are ordered byskill_runsDESC, then cohortrunsDESC, then a stable(repo, agent_role, backend, skill, level)tiebreak, and the list is capped atlimitrows (defaultSKILL_MATRIX_ROW_LIMIT= 100; a non-positivelimitdisables the cap). The agent-exit event-filter short-circuit applies (no catalog read either). NULLagent_role/backendbucket under"unknown". Sameextras JSONB/ no-DDL andTRACK_SKILL_TRIGGERS-off caveats asget_skill_trigger_rates.get_skill_adoption(base table) — per-(skill, level)×(repo, agent_role, backend)adoption aggregated by logical agent session rather than by raw agent run, so a resume chain that pulleddevelopacross several ticks counts as one adopting session, not several. Three base-table scans combine in Python. The first applies the full reporting-window filters and selects the active sessions plus the window-scoped diagnostics; the second reads each active session's evidence from everyagent_exitrow before the window end, deliberately dropping the window start and the stage filter (WindowFilters.historical_scope) so a load from a prior stage or from before the window stays visible, while the retainedendbound stops a later load from leaking backward. The third reads the repos'repo_skill_catalogrecords underWindowFilters.catalog_scope(date and repo only — an issue or stage pushed onto a repository-level record would drop every row), and the twoagent_exitscans resolve against it. A session is keyed byresume_session_id, thensession_id, then the row's primary key (an ID-less row is its own session, never merged into one anonymous bucket). Both session scans read each row'sskill_levelsmap beside its names, so an offer and a load are matched by source level as well as by name and a session offered a repository's owndevelopis counted apart from one offered a global skill of that name. A name no level map covers is filled from the catalog scan (skill_provenance.py): the level the row's repository offers that name at, where it offers exactly one, so a claude session's unclassified offer and load land in the same cell a codex session's classified ones do (develop/projectreading 3 of 26 sessions rather than that cell beside adevelop/unknownone). A name the catalog never offered, or one it offers at two levels, staysunknownon both sides — so a legacy session's load still counts as adoption of what it was offered — and a level the row itself recorded is never overwritten. The fill is applied to all three evidence categories alike (window loads, incidental references, and the historical availability and loads), since filling one and not another would strand a session outside its own denominator; it is a read-side resolution, so a past window's rows correct themselves the next time the page is drawn and no record is rewritten.sessionsis the denominator — sessions in the cohort with the skill available (itsskills_availablelisted it, or a legacy load with theskills_availablekey absent implied it — an explicit empty set does not) — andadoptedcounts the sessions that loaded it, once per session, with a derivedadoption_rate.invocationsis the cohort's windowagent_exitrun count (every run, so a lowload_rowsreads against it);load_rowscounts the window runs that loaded the skill andincidentalthe window runs that made a path-only reference to it — independent buckets, so a run that both loaded and inspected aSKILL.mdincrements both. All three are window-scoped, so a pre-window load counts towardadoptedbut not toward them. Rows are ordered bysessionsDESC, thenadoptedDESC, theninvocationsDESC, then a stable(repo, agent_role, backend, skill, level)tiebreak, and the list is capped atlimit(defaultSKILL_ADOPTION_ROW_LIMIT= 100; a non-positivelimitdisables the cap). The agent-exit event-filter short-circuit (no scans at all, the catalog one included), NULL"unknown"bucketing, andextras JSONB/ no-DDL /TRACK_SKILL_TRIGGERS-off caveats matchget_skill_trigger_matrix.get_issues(base table) — date / repo-bounded one-row-per-(repo, issue)overview: event count, first / last activity, latest non-null stage, agent-exit count, cost / token totals,max_review_round,failed_agent_runs,max_retry_count. Bounded bylimitand ordered bysort_by("last_seen"default,"cost"orders bySUM(cost_usd) DESC NULLS LAST; unknownsort_byraisesValueError).get_issue_events(base table) — full event trace for a single(repo, issue)pair, oldest first. Beyond the nine columns the drill-down tabulates, eachIssueEventRowcarries the promoted run columns a human-wait record is correlated by (agent_spec,session_id,resume_session_id,review_round,retry_count,timed_out) and the row's decodedextrasblob, where a park'sreason,route,pr_number, andsharide — seeevent-streams.md. A NULL column reads asNone, and a NULL, damaged, or non-object blob as an empty mapping, so a legacy row still reads. The page renders none of them.get_hourly_heatmap(base table) — 7×24 weekday/hour activity cells fromEXTRACT(DOW)/EXTRACT(HOUR)over(ts AT TIME ZONE 'UTC') + tz_offset_hours * INTERVAL '1 hour'(normalizing first guards against a non-UTC session timezone re-shifting the buckets) with per-cell event count +input + output + cache_read + cache_writetoken total.tz_offset_hours(default0, parameter binding only — never spliced) lets the dashboard bucket in a non-UTC zone.get_review_round_breakdown(agent-run view) — perreview_round_bucketruns / failed counts +total_cost_usd, plus per-role (developer_*/reviewer_*) run counts and cost, each role's cost further split into cache vs no-cache (*_cache_cost_usd+*_no_cache_cost_usd). The split is proportional: each run's cost is weighted by(cached_tokens + cache_read_tokens + cache_write_tokens) / (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens)into the cache stack and the complement into no-cache.cached_tokensis the Codex "portion of input served from cache" counter and is already insideinput_tokens, so it stays out of the denominator to avoid double-counting. Token-less rows attribute their full cost to no-cache. NULL buckets surface as"unknown".get_backend_daily_tokens(agent-run view) — per(day, backend)token totals feeding the hero chart's "By backend" stacked-area toggle.get_cost_coverage(agent-run view) — percost_sourcerollups carrying both runs andtotal_tokens. Theunknown-pricecohort is exposed verbatim (never collapsed into a generic "unknown") because it is the maintenance signal for the price tables inobservability/usage/prices.py. NULLcost_sourcebuckets under"unknown".
Filter contract. The agent-run view has no event column (its WHERE event = 'agent_exit' is baked in), so
view-backed functions cannot push an event IN (...) clause down. They honor the dashboard's event-filter contract by
short-circuiting to empty when the operator's events selection excludes agent_exit (or is cleared). Rollup readers
preserve the same contract through build_rollup_window_where, which emits a tautologically-false predicate on a
cleared multiselect and a parameterised IN (...) on a non-empty one.
The rollup window helper translates the dashboard's midnight-aligned UTC [start, end) datetimes to day >=
start.date() AND day < end.date() predicates so the (day, repo) index drives a date-range scan. Sub-day-aligned
bounds collapse to day granularity (the rollup carries no finer resolution), but the dashboard never passes those.
Connection model. Each function returns a frozen dataclass or list of dataclasses. ANALYTICS_DB_URL unset
short-circuits every function to an empty / zero-valued result with no connection attempt, mirroring the sync's no-op
contract. Connection or query failures (driver-level psycopg errors, schema mismatches, network unreachable) are wrapped
in a single AnalyticsReadError whose __cause__ preserves the underlying exception. The psycopg import is deferred to
call time inside default_connect; tests inject a fake connect(db_url) -> connection factory.
Every public reader accepts an optional conn= so a caller (typically the dashboard, inside an analytics_connection
scope) can run many reads on a single shared connection instead of paying the ~1 s psycopg handshake per call; absent
conn=, the open-per-call / close-in-finally path runs unchanged. A caller-supplied conn= always wins over the URL
short-circuit.
analytics_connection(*, db_url=None, connect=None) is a context manager that maintains a single thread-local
persistent connection. The first with block opens the socket (real psycopg connections open with autocommit=True);
subsequent with blocks on the same thread reuse it; a broken-connection error (OperationalError / InterfaceError)
inside the scope close-and-replaces the cached socket before re-raise. close_thread_local_connection() drains it
explicitly for shutdown hooks or test teardown. The thread-local cache is keyed on the resolved URL: a later with
block on the same thread requesting a different db_url= closes the stale socket first. The connection is not part of
any Streamlit cache key (a raw psycopg.Connection is not hashable). Close-time exceptions are logged and swallowed,
through the orchestrator.analytics.connection logger. That name is spelled out literally in
observability/analytics/query/connections.py rather than derived from the module path, so an operator log filter
selects on it regardless of which module the close lives in.
The read model is deliberately separate from the sync: observability/analytics/sync/ owns the JSONL → Postgres write
path, while reads have a different error story and injection shape.
Dashboard (orchestrator/apps/analytics_dashboard.py)¶
Streamlit app over the read model, run as a source-checkout developer tool with the optional dashboard dependency
group. pipx installs the polling runtime without dashboard dependencies. Streamlit (and its transitive
pandas), plotly, and every dashboard owner the page composes — the chart owners that reach Plotly and the
plotly-free theme among them — are imported inside the pass
that reaches them — importing the launch path from a test or non-dashboard caller does not require the group to be
installed. A regression-guard test in tests/apps/test_analytics_dashboard_launch.py asserts that loading
orchestrator.apps.analytics_dashboard keeps streamlit, pandas, plotly, and
orchestrator.observability.dashboard.charts out of sys.modules.
Module layout. orchestrator/apps/analytics_dashboard.py is the canonical streamlit run target, the only one,
and the whole
page composition: the entrypoint, the three handles every pass draws with, the chrome, the unconfigured-database
refusal, the pass that opens the page on the two reads no filter narrows, and the one that draws the window they
allow. Everything beneath it is an owner under observability/dashboard/: no root-level module answers for any part
of this page, so a name is reached where it is defined and there is no second site a mock could be left on.
Where a patch has to land follows the call path, which
runs entirely through observability/dashboard/: the page reaches the
controls it opens on, the staged plan, the wave dispatch, and the render passes those drive on the owners that hold
them, so a test intercepts them with
patch.object(page_controls | read_plan | dispatch | page_pipeline | chart_sections | page_sections, ...). The
Plotly toolbar every figure is drawn under is render_config's own mapping, and the shapes
the pipeline
threads are page_models'. A card builder follows the same rule, so a case intercepting the banner stack the
first-wave pass draws patches
patch.object(card_html, "insights_html", ...), and one intercepting the topbar or the filter line patches
summary_html. Each panel below sits the same way. Its
render is intercepted on the section owner that orders it — chart_sections for the five figure cards,
page_sections for the four beneath them — while what one draws with is the panel owner's own module-scope import, so
a case that has to intercept the adoption table, the trigger-rate one, the matrix, or either sort
parse patches skill_panel or skill_trigger_panel, and one that has to intercept the issues table, the efficiency
card, the coverage bar, or the ranking depth patches issue_cost_panel. The paired lifecycle bars, the
repository-spend pair beneath them, and the activity grid under both sit there too, and a figure builder is on that
list beside the card ones: the
per-stage and per-review-round builders are stage_cost_panel's own module-scope imports off charts/cost_stage.py
and charts/cost_review.py, the per-repository ranking and per-day strip are reliability_panel's off
charts/cost_repo.py and charts/throughput.py, and the weekday-by-hour grid is activity_panel's off
charts/heatmap.py, rather than a
handle the pipeline hands down, so a case intercepting any of the five patches the owner that names it. Those
three read PLOTLY_CONFIG off render_config as a module attribute at call time, so a case pinning the toolbar for
any of them patches that owner as well. The
zone selectbox inside that last card is activity_panel's too — the options it offers and the formatter each is
written by are its module-scope import of filters — so a case naming either patches there. The recent-run listing
sits
the same way as the panels: the render is intercepted on recent_runs, and the offset shift each ts is converted
through is that same owner's module-scope import of
filters, so a case that has to intercept that shift patches there rather than the widget module. So does the
per-issue trace beneath it: its render is intercepted on drilldown, while the scope its one uncached read is issued
inside
is that owner's import of scoped_reads, so a case that has to answer that read patches
patch.object(scoped_reads, "scoped_read", ...). The hero
usage card sits that way too: the render is intercepted on usage_panel, and the card header it is titled by, the
usage
figure it draws, and
the Plotly defaults it hands that figure are that owner's own module-scope imports.
The repo-root sys.path shim that lets streamlit run resolve the absolute orchestrator.* imports comes from
orchestrator/apps/bootstrap.py (ensure_repo_root_on_path), the one both pages take it from.
tests/apps/test_analytics_dashboard_launch.py reproduces the analytics page's launch shape without
installing Streamlit: the file executes with only its own directory on sys.path (Streamlit's shape, not the repo
root's), a decoy orchestrator package sitting behind it on the path cannot answer for the real one, and a package
import resolves its shim qualified — so a stray top-level bootstrap is never probed.
The state a run carries
lives under
orchestrator/observability/dashboard/, split by what it decides: windows.py for the reported span and the presets
that name one, filters.py for the offset, issue, stage, and cache key it is narrowed and displayed by,
date_controls.py for the five slots the bar that window is picked in is laid out across together with the label and
the inline presets drawn in the first two of them, date_filter.py for the bar itself — the window a preset
opens the pickers on, the inclusive days they hand back, and the half-open window plus the filter-line slot the caller
leaves with — page_controls.py for the band that bar sits in and the load the choices made there open — the sidebar
a run is narrowed in, the offset its timestamps are displayed against, the filters those raw selections normalize
into, the controls the band is read back as, and the staged plan beneath it — read_mode.py for the parallel-read
knob, the flag its import binds, and the unconfigured-database
message,
read_plan.py for the two waves a load is staged into, the minute each cached entry is held for, and the current /
previous key pair they are issued under,
fanout.py for running one wave of named readers the way that flag said, dispatch.py for driving both waves around
the render between them — one spinner over the pair, one banner and a stop when a read cannot reach the database, and
one dashboard.load: line when the load comes back — rollups.py for the seven of those readers a
headline or lifecycle section is drawn from — with the hundred-row cap the run list among them is read under, and the
ranking depth the spend table borrows from the KPI owner — breakdowns.py for the six a comparison panel is drawn
from, each naming the rollup or breakdown query owner that answers it, and skills.py for the three a skill panel is
drawn from, each naming the skill query owner. What one read of that wave then goes through
lives beside them: scoped_reads.py for the thread's analytics connection it is issued inside, filter_binding.py
for the filters its cache key is read back as, and static_metadata.py for the extent and filter vocabulary a page
opens on, the TTL both are cached for, and the banner a failed one stops the run with. Above every panel that wave
feeds, insights.py holds the two observations a window is worth interrupting for — runs exiting non-zero, and runs
the parser could not price — the ratio each is raised at, and the banner line a crossing is rendered as; directly
beneath those, kpis.py holds the four numbers the headline tiles report — the move against the window before it,
the run-health tiles, the order and depth a spend table is cut to, and the share of spend that was a second pass.
Beside it, kpi_series.py holds the per-day spend, token, and resolved lines drawn under three of those tiles,
together with the two token totals they are counted by and the throughput pair reported beside them, and
kpi_strip.py the strip itself — what one is built from, the scalars a window and the one before it are reduced to,
and the four
display entries a page opens with. sparkline_points.py and sparkline_html.py are how one of those lines reaches
the tile above it — three of the strip's four tiles carry one. The first places each day in a box too narrow for an
axis, scaled to the window's own range, floored at an epsilon where a window has none, and left unprojected where the
window reported nothing at all; the second writes that projection as one polyline and the same trace closed along the
bottom edge of the box into the tint under it, holds the tile's room with an empty box where there was nothing to
project, and binds the values / color / w / h surface a caller asks for one through.
summary_html.py is the band that strip sits in — the banner naming what the database holds, the line under the
filter bar restating what a run narrowed it to, the pill one tile's move against the window before it is annotated
with, and the four tiles assembled around them. A rise reads red and a drop green, because these numbers are costs;
invert swaps the hue for the readings where up is the good direction without moving the arrow off the value's sign,
and a window with nothing to compare against or one that did not move renders no pill at all. What reaches the markup
as caller text is escaped — the banner's span label and spend figure, and each tile's label, value, and sub-line —
while the counts beside them and the filter line's own dates carry nothing to escape; the pill's value keyword and the
banner's six keyword-only readings are bound as explicit signatures so both still answer the call every caller spells.
card_html.py is what the banners and the run-health tiles reach the browser as, together with the header every panel
beneath them is titled by: the hidden mark css.py selects a card's container by, the banner stack whose severity
picks a class and a glyph, and the reliability strip whose numbers the calling page's own formatter renders.
tables.py is the markup beside it —
the compact table the four hand-rolled panels are listed in: the stylesheet each scopes to its own class, the header
and body they are assembled from, and the bar width, short repository name, missing count, and unpriced amount a cell
reports. issue_table.py is the first of those four: the six columns a window's costliest issues are ranked into,
the rules their in-row bars and status pills are painted by, and the readings one issue is reduced to and rendered
as — its bar a share of the widest row in that table, its review round toned from the third one on, and its run
health a clean pill wherever nothing failed. skill_trigger_table.py is the second: the six columns a
(role, backend) cohort's skill use is reported in, its rate bar a share of the busiest cohort in that table, and
unknown the label a category the sink left empty is read under. The last two are the panels an operator can reorder,
so each arrives across five owners. The third is the per-session adoption table, the page's primary skill metric:
skill_adoption_columns.py for the ten columns it is read across, the key each is ordered by, and the adopt_sort /
adopt_dir pair a heading writes — with the two invocation diagnostics among those columns counted apart from the
session pair so neither can be read into the rate between them; skill_adoption_sort.py for the parse that reads the
pair back and the repository-then-rate order a table nobody sorted opens in; skill_adoption_headers.py for the
header row each column is an in-tab sort link in; skill_adoption_rows.py for what one cell says under its five
naming columns — the source level among them, so two rows the other four read alike stay legible as the two
definitions they are — keeping the undefined rate of a skill nobody was offered apart from the real
zero of one nobody loaded; and skill_adoption.py for the panel those cells are sorted into, the
TRACK_SKILL_TRIGGERS-naming notice a window with no session evidence renders instead, and the message a caller
drawing one slice of a window's cells names in that notice's place. The fourth is the trigger
matrix, split the same way: skill_matrix_columns.py for the eight columns it is read across, the key
each is ordered by, and the mtx_sort / mtx_dir pair a heading writes; skill_matrix_sort.py for the parse that
reads that pair back — a stale column or a lone direction degrading to the default rather than raising — and the
repository-then-rate order a matrix nobody sorted opens in; skill_matrix_headers.py for the header row each column
is an in-tab sort link in, with the arrow only the active one carries; skill_matrix_rows.py for what one cell says
under those same five naming columns, its zero and derived rate toned down together while the cohort's run total
stays plain; and skill_matrix.py for the panel those cells are sorted into and the TRACK_SKILL_TRIGGERS-naming
notice a window with no catalog-backed cell renders instead.
skill_panel.py and skill_trigger_panel.py are the two cards three of those four panels are reported on. The first
is the one the page draws: the aggregate rates and the trigger matrix open it in one collapsed expander, adoption
follows in a collapsed section per source level — project, user, harness, and a fourth section for the cells no record
classified rather than a split that drops them — one notice answers a window with no agent_exit row for the whole
card rather than for each table in it, and the caption under those sections qualifies a window nobody adopted anything
in — naming whichever of availability, loads, or incidental references it did carry — instead of recommending a
TRACK_SKILL_TRIGGERS the presence of a row already proves is on. Whether that evidence was there is what the fold
above is handed, so a window where no run triggered reads as a genuine no-trigger or as a prompt to switch tracking
on accordingly. The second is the card the section led with before adoption did; nothing in the render pipeline
draws it now, and its prompt is unconditional, since trigger rates alone carry no per-session evidence to tell those
two windows apart.
recent_runs.py is the listing under those four panels rather than a fifth among them — the runs behind the readings
above it, projected into the columns one is scanned by and the offset the sidebar picked, drawn as st.dataframe
because a raw listing carries no bar, pill, or sortable heading Streamlit's own table cannot already handle. It opens
collapsed so a window's worth of rows does not push the per-issue drill-down off the screen the page ends on, and a
window with no agent_exit row renders the notice rather than an empty frame.
drilldown.py is that drill-down — one issue's events in the order they happened, which is where an operator lands
once a run in the listing raised a question the row cannot answer. It is the one page read issued outside the cached
wrappers, because it is scoped by an issue on top of the window and filters those keys are hashed from, and it still
goes through the scope owner so it runs on the socket the waves above it opened. A repository has to be picked before
a number narrows anything, since GitHub issue numbers repeat across repositories, and the section names the control
that answers it; an empty window names the repository, issue, and filters it found nothing under; and a failed read
banners itself and returns rather than stopping a page whose panels already rendered.
drilldown_request.py is the call shape that section is still reachable under: the seven keyword arguments a caller
outside the render pipeline names, the declared signature they are bound through — the same object the adapter
reports, so the historical shape stays one description — and the typed request they are read back as before the page
state is rebuilt from it.
page_states.py is what is drawn where none of those panels can be, and it holds two dead ends of different kinds. A
database nobody has ingested into has no extent to pick a window from, so there is no filter bar to offer: the banner
is drawn with every count it carries zeroed, the notice names the sync command that fills the table, and the script
stops where it stands. A window that merely matched nothing still has a page around it, so that one keeps the chrome
already rendered above it, says which way to broaden, and hands the page on to the trace at the foot of it — an
operator narrowing to one issue is exactly who lands on an empty window, and that trace is scoped by the issue on top
of the window rather than by the cache key the skipped reads share. Emitting the load line is the other half of that
hand-off: dispatch.py times a load off the line run_read_waves ends on, and a window that short-circuits the
second wave never reaches it, so this owner reports the load off the plan's own clock and the first wave alone rather
than the full inventory nobody paid for. The third render is the footer beneath a page that did draw, restating the
window span and the run count everything above it was measured over — closed on the day before the window's end,
since the reads beneath the page are issued under ts < end and restating end itself would name a day none of
those numbers covered.
usage_panel.py is the card above every one of those panels — the first one under the KPI strip, so it answers the
question the page is opened with: whether a day's cost tracks the work behind it. The figure carrying both readings is
the usage chart family's; this owner decides the card around it — the header naming it, the toggle deciding what it
stacks, and the rows the chart is handed for the mode an operator picked. The toggle is a two-value radio because
neither stack is the drilldown of the other: by token type is what a day's tokens went on, by backend is who spent
them. Streamlit reruns the whole script on every interaction, so the picked mode is kept in the page's own session
state under a key apart from the radio's own and the radio is seeded from it by index, since the widget takes an
option's position rather than its value. The per-backend rows are totalled per day here — the same (day, backend)
cell can arrive more than once, and only when the backend stack is the one being drawn, because the token-type bands
already ride on the time-series points.
Two more panels are drawn as markup rather than as a figure: backend_card.py for what a run on one backend
is worth — the cost of a million tokens, the cost of a run, and the share of billable input the cache answered, each
divided through one guard so a window a backend barely ran in reads zero rather than raising — and coverage_card.py
for how much of a window's spend the parser could price, sized by token share whenever the window carries any and by
run share only when it does not, drawn as one bar and the legend beneath it.
stage_cost_panel.py, issue_cost_panel.py, and reliability_panel.py are the three sections that spend is compared
across, each a pair of
7:5 columns rather than a single panel. The first pairs the two lifecycle axes — spend by workflow stage beside spend
by review cycle, each figure built by naming charts/cost_stage.py and charts/cost_review.py directly rather than
by reaching a builder off a handle — and pins both to one height taken off whichever of the two reads returned more
buckets, because a horizontal bar family sizes itself by its own row count and two panels left to size themselves
would put bars of two different thicknesses either side of one gutter. The second pairs the work with the agent that
did it:
the window's costliest issues ranked on the left, one efficiency card per backend on the right, and the coverage bar
closing that column because it qualifies the money the cards above it report — drawn only where the window carries a
cost-source split. Its two columns answer an empty window differently, since a window can carry runs the parser could
not price while a window with no agent_exit row had nothing to run at all. The third pairs the money with whether
the runs behind it held up: the window's spend by repository on the left, the six run-health tiles over the per-day
strip of the issues those runs resolved on the right, each figure built by naming charts/cost_repo.py and
charts/throughput.py directly. That strip is handed the window's bounds so a day nothing resolved on stands as a
zero bar, and the closing bound is the day before the window's end — the reads beneath the page are issued under
ts < end, so drawing through end itself would add a trailing day none of them covered.
activity_panel.py closes that run with the one card that keeps the clock instead of reducing the window to a
reading: the weekday-by-hour grid off charts/heatmap.py, headed by the zone its hours are read in and carrying the
selectbox that picks that zone. The control sits in the card because it changes what the figure means rather than
which rows reach it, and the offset is formatted once so the header and the x-axis cannot name different zones over
one set of cells. Nothing there shifts a timestamp: the cells arrive bucketed by the read, which is issued under the
offset the page reads back off the same session key the selectbox writes.
page_models.py holds the seven frozen shapes a render carries between all of that: the caller's Streamlit, pandas,
and theme handles, the selections every read is narrowed by, the controls and page they open on, what one load
answers with, and the rows, totals, and counts the paired repository-spend and run-health section is drawn from.
Streamlit reruns the whole script on every interaction, so a render is one pass and freezing those
shapes is what keeps a section from narrowing the window the sections beside it were handed. Two readings are derived
rather than stored: the issue scope answers nothing until a repository is picked, since GitHub issue numbers repeat
across repositories, and the window span is measured in whole days and floored at one, since it is the divisor of
every per-day rate.
render_config.py holds the Plotly configuration each figure below is handed — the hover toolbar switched off once
for the whole page rather than per call site, published as a read-only proxy every call site copies before handing it
to Plotly.
What those reads are drawn as sits one level down, under charts/, where primitives.py holds what every figure
family is built out of, cost_layout.py the frame the horizontal cost families share, cost_horizontal.py the
generic spend ranking, cost_repo.py the per-repository one drawn through it, cost_stage.py the per-stage cache
split, cost_review.py the per-review-round one beside it, heatmap.py the weekday-by-hour grid,
throughput.py the per-day resolved-issue strip, and
usage_bands.py / usage_series.py / usage_axis.py / usage_traces.py / usage.py the bands, day span, stack
heights, aligned axes, traces, and assembled hero figure the usage family draws its reads as (see Chart builders
below).
Nothing above these owners re-exports them: the page names each where it is defined, so a name has one home and a
mock has one place to land. Streamlit is never imported in these
helpers — st, the theme, and the pandas handle beside them are passed in as parameters, and the figures are the
chart owners' own rather than a handle threaded down.
Setup and launch commands are in the developer guide.
Page chrome. A sticky topbar carries the page title with the data extent / repo / event summary on the left and the
in-range spend pill on the right. A sticky filter bar exposes 3D / 7D / 30D / All inline presets (anchored at
the data extent's max timestamp and clamped to its min) plus two date inputs for arbitrary windows within the extent.
The sidebar surfaces a Custom preset fallback, a repo selector, event / stage multi-selects, and a #123 / 123
issue-number input.
Caching. Every per-filter read is wrapped in st.cache_data keyed by the immutable DashboardCacheKey(start, end,
repo, events, stages, issue), so a filter change invalidates every cached query in lockstep. dashboard/read_plan.py
builds those wrappers under WIDGET_CACHE_TTL_SECONDS = 60 (1 min), so a window nobody changes goes back to Postgres
on the first rerun after that minute rather than on every widget interaction. get_data_extent and
get_filter_options carry no filter inputs and live in argument-less wrappers under the longer
STATIC_METADATA_TTL_SECONDS = 300 (5 min) TTL so the sidebar / topbar only re-hit Postgres when a sync run
ingests new events.
Two-wave loading. The 16 widget reads are staged into two waves by dashboard/read_plan.py:
- First wave (6 reads).
summary,prev_summary,ts_points,review_round_rows,throughput_rows,cost_coverage_rows— feeds the topbar, filter meta, insight banners, and KPI strip. - Second wave (10 reads).
stage_rows,agent_exits,issues_rows,backend_rows,repo_rows,heatmap_rows,backend_daily_rows,skill_adoption_rows,skill_rows,skill_matrix_rows— feeds the rest of the body.
dashboard/dispatch.py drives both waves and renders the above-the-fold chrome between them on the main thread
(worker threads only return data through futures, so every st.* write runs on the main thread). The second wave is
skipped on an empty window. A single inline st.spinner("Loading analytics…") brackets both waves; a read error from
either wave surfaces as one st.error + st.stop.
Body layout, top to bottom:
- Computed insight banners (failure rate ≥ 10 %, unpriced cost coverage ≥ 10 %).
- Four-tile KPI strip — total spend, total tokens (
input + output + cache_read + cache_write), cost / resolved issue, rework share — each with an inline-SVG sparkline and previous-window delta where applicable. - Hero
usage_over_timestacked-area + cost-line chart with a "By token type / By backend" toggle. - Side-by-side
cost_by_stageandcost_by_review_roundcards; the stage card stacks each stage bar into no-cache + cache cost, and the review-round card groups development and review cost bars per round with each role's bar further stacked into no-cache + cache cost — so the operator can see how much per-stage and per-round spend still bypasses prompt caching. - 7/5 split: top-cost issues table (Issue with in-row cost bar, Cost, Runs, Review rds, Retries, status pill) +
backend-efficiency cards (
$ / 1M tok,% cache hit,$ / run) above the cost-source coverage bar (sized by token share). - Another 7/5 split:
cost_by_repobars + six-tile reliability panel (agent runs / success rate / resolved / rejected / failures / timeouts — all sourced from the sameSummarywindow-wide aggregate) above the issues-resolved-per-day bar chart with explicit zero days backfilled. - 7 × 24 weekday × hour activity heatmap rendering token volume, with an in-card
UTCoffset selectbox (range-12 … +14, defaultUTC+7) that controls both the heatmap bucketing and the wall-clock conversion of thetscolumn in the recent agent-runs table below. The widget binds tost.session_state["tz_offset_hours"]; the offset is read before the second-wave fan-out so the heatmap query buckets in the chosen zone, and the card subtitle / x-axis title render the matchingUTC±Nlabel. - "Skill adoption" panel — a fold-out invocation-level diagnostic above the primary per-session adoption matrix,
which is itself split into one collapsed section per source level. The card opens on a collapsed
st.expander("Invocation-level diagnostics · per-run skill triggers") carrying the older per-run views as a clearly named diagnostic: the per-(agent_role, backend)aggregate table (_skill_triggers_htmloverget_skill_trigger_rates, showing runs, skill runs, a trigger-rate bar, and total trigger count) and, below it, the per-skill trigger matrix (_skill_matrix_htmloverget_skill_trigger_matrix) with columns Repo / Role / Backend / Skill / Level / Runs / Runs with skill / Trigger rate — the sameLevelcolumn the adoption table beneath it carries, so a catalog-paddedprojectrow can sit beside anunknown-level row for a skill whose level neither the run nor the repository's catalog settles. The matrix folds each repo'srepo_skill_cataloginto the observed triggers so a skill the repo offers but no cohort fired surfaces as an explicit (muted)0"Runs with skill" cell (and a matching muted0%trigger rate) rather than a missing row (the cohortRunstotal is never muted); its headers writemtx_sort/mtx_dirparams (parsed byparse_skill_matrix_sort) and default to Repo ascending, then Trigger rate descending. Under that fold-out the adoption cells are drawn in one collapsedst.expanderper source level, in the order one definition shadows another: "Project-level skills · defined in the repository", "User-level skills · installed for the operator", and "Harness-level skills · built into the CLI", each rendering the same sortable table over its ownlevelrows. A fourth section ("Unclassified skills · no source level recorded") is drawn only when the window carries a cell neither the record nor the repository's catalog classified — a claude run's load of a name the repo never offered, or offers at two levels — so such a cell is reported rather than dropped by the split. A cell whose blank level the catalog does settle lands in that level's section instead, which is what keeps a claude cohort's sessions inside the "Project-level skills" table rather than in this one. A named level the window has no cell of renders a short line saying only that (No user-level skill was recorded in this window.), never the switch-naming notice, since a sibling section's rows already prove tracking is on. The table each section draws (_skill_adoption_htmloverget_skill_adoption) renders one row per(repo, agent_role, backend, skill, level)read-model cell with columns Repo / Role / Backend / Skill / Level / Sessions / Sessions using skill / Adoption rate / Invocation loads / Incidental references.Levelis the source level the skill was defined at (project/user/harness, orunknownwhere neither the record nor the repository's catalog classified it) and is what decides the section a row lands in, so two rows carrying the same four names ahead of it are legible as the two definitions they are — a repository's owndevelopbeside a same-named global one. A claude session, whose stream names no source directory, does not add a third such row: its unclassified offers and loads are filled from the catalog and merge into the cell for the definition they came from. The table counts skill use by logical agent session, not by raw run:Sessionsis how many sessions in the cohort had the skill available,Sessions using skillthe subset that loaded it, andAdoption ratetheir share (adopted / sessions, once per session). The two trailing columns are the window-scoped invocation diagnostics:Invocation loadscounts the window runs that loaded the skill andIncidental referencesthe window runs that made a path-only reference to itsSKILL.md. The load and incidental buckets are independent, so a run that both loaded and inspected it increments both, and an incidental mention is a separate column that can never raise the adoption rate (a cell with no available session renders a muted—rate rather than a misleading0%). The read model caps the list at 100 rows (Sessions DESC then Sessions-using DESC then Invocations DESC) across the window's cells as a whole, before the level split; by default rows display sorted by Repo ascending, then Adoption rate descending. Each column header is a clickable sort control writingadopt_sort/adopt_dirquery params (parsed byparse_skill_adoption_sort), with a ▲ / ▼ indicator; an unknown / absent param falls back to that default order, and every section reads that one pair, so a click reorders all of them at once. The three tables degrade differently with the switch off: per-session adoption only carries signal onceTRACK_SKILL_TRIGGERShas recorded per-run skill fields, but the trigger-rate table still counts everyagent_exitrun and the matrix still shows catalog-backed zero rows (therunsdenominator andrepo_skill_catalogrecords do not depend on the switch). When rows are present a zero-adoption window captions a neutral genuine-0% result beneath the level sections (a present row proves tracking is on), an adoption window with no cell at all renders the adoption table's fallback notice naming the switch once, in place of those sections rather than inside each of them, and the matrix shows its own fallback notice when no catalog-backed matrix can be built (no catalog records matched and no run fired a skill). - Recent agent-runs table as a collapsible expander; the
tscolumn is shifted to the wall-clock of the selected UTC offset viashift_ts. - Per-issue drill-down when a number is entered.
Filter contract. build_window_where distinguishes three cases for the event / stage selections: None is "no
filter on this column", a non-empty sequence emits a parameterised IN (...), and an empty sequence emits a
tautologically-false predicate (FALSE). The event multiselect maps straight through (event is NOT NULL in the
schema). The stage multiselect routes through resolve_stage_filter(selected, available) because options.stages only
lists non-null stages: the all-selected default collapses to None so NULL-stage rows are included; an explicitly
cleared selection still emits []; a proper subset passes through verbatim. Without this asymmetry the default
dashboard would silently exclude stage_evaluation rows on issues with no workflow label — and two families that
carry no stage at all: the repo-level repo_skill_catalog snapshot, and the terminal_artifact_cleanup result,
which is about a finished issue's artifacts rather than a workflow state. A narrowed stage selection still drops
all three, which is the filter working as asked rather than a gap. The issue number acts as a
SQL-level filter when a specific repo is selected AND triggers the drill-down section; with the repo filter on "All" it
stays inert (GitHub issue numbers are not unique across repos).
Parallel read fan-out. Setting DASHBOARD_PARALLEL_READS=on (or 1 / true / yes, case-insensitive) flips the
16 widget reads from sequential to a ThreadPoolExecutor capped at eight workers. Each worker opens its own
thread-local psycopg connection via query/connection_cache.py's analytics_connection() — psycopg.Connection is
not thread-safe,
so sharing one socket across workers would corrupt the wire protocol. dashboard/dispatch.py emits a single INFO log
line on every dashboard load — dashboard.load: total=X.Xs reads=16 parallel=true|false on a full render, or
reads=6 when the empty-window short-circuit skips the second wave — so the two paths can be A/B'd with grep
dashboard.load streamlit.log. That line's logger is named orchestrator._dashboard_read_dispatch as a pinned
literal rather than after the module holding the emit, so a handler or level selection aimed at it keeps working
across a move. An AnalyticsReadError raised by any worker propagates verbatim from the first failing future.
Chart builders. observability/dashboard/charts/ holds pure Plotly figure builders: usage_over_time
(stacked-area + cost-line overlay with mode="type" / mode="backend" switch), cost_horizontal_bars (shared
primitive), cost_by_repo (thin adapter over cost_horizontal_bars), cost_by_stage (per-stage horizontal bars with
each bar stacked into no-cache + cache cost under barmode="stack"; the cache segment uses a translucent shade of the
stage's base color so the pair stays visibly tied to the stage, and only the outer cache segment carries the per-stage
dollar text), cost_by_review_round (grouped development/review bars per round, each role's bar further stacked into
no-cache + cache cost via offsetgroup + barmode="relative"; the cache segment uses a translucent shade of the role's
base color so the pair stays visibly tied to the role), hour_weekday_heatmap (faint-to-saturated accent gradient over
per-cell token totals, Sunday-first, with a tz_label parameter that annotates the x-axis — the caller passes the
matching offset to get_hourly_heatmap so cells already reflect that zone), and done_per_day_bars (resolved-per-day
bars with explicit window_start / window_end for zero-day backfill). Each builder is defined by the owner named
for its family -- usage_over_time / backend_per_day by charts/usage.py, the three cost adapters (cost_by_repo /
cost_by_stage / cost_by_review_round) by charts/cost_repo.py, charts/cost_stage.py, and
charts/cost_review.py with cost_horizontal_bars under them in charts/cost_horizontal.py,
hour_weekday_heatmap by charts/heatmap.py, and done_per_day_bars by charts/throughput.py --
and a panel names that owner directly. The shared low-level chart
primitives
(empty_figure, the money / mono-textfont / two-line-tick and panel-height / legend helpers) live under
orchestrator/observability/dashboard/charts/primitives.py. Every chart family names that owner directly, so the
dependency
runs one way and a direct import of any chart module is cycle-free. The frame the three horizontal cost families are
drawn in (the panel margin, the USD axis, the
height, and the CostBarTrace request one series of bars is built from) lives beside it in
orchestrator/observability/dashboard/charts/cost_layout.py, and cost_horizontal_bars itself -- with the ordering,
tinting, and flip behind its bars, and the pinned Signature that keeps items its first parameter -- in
charts/cost_horizontal.py. cost_by_repo sits on top of that ranking in charts/cost_repo.py, deciding only how a
repository reads: the short name its bar is labelled by with the owner/ prefix dropped, the agent-run count on the
sub-line rather than an event count, the page accent on every bar, and the shared placeholder in its own words for a
window matching no repository. cost_by_stage sits beside the ranking in charts/cost_stage.py -- the ranking, the
full-price fallback that lets a row carrying only a total still draw at its true length, the runs sub-line, and the
lightening that shades a cache half from the stage's own hue, which is also where the per-review-round split gets the
shading for its own cache halves. cost_by_review_round is that split, in charts/cost_review.py -- the round order
and labels the rows are laid out and read by, the two-role sub-line beside them, the totals each role's bar is
labelled by, the four series the two roles and their two halves are described as, and the row height a panel carrying
two bars per row is sized with. The heatmap and
throughput families sit under the same package:
orchestrator/observability/dashboard/charts/heatmap.py builds the grid -- and draws its own empty-state annotation
over it rather than routing through the shared placeholder, because an empty heatmap is still legible -- and
orchestrator/observability/dashboard/charts/throughput.py the per-day strip, naming the shared placeholder for the
case that reaches it -- a caller who passed no rows and not both window bounds, since only both of them turn the
window into a calendar to draw zero bars across. A test that has to intercept a builder patches the owner named for
its family, and every builder reports that owner as its defining module.
No owner under charts/ names Plotly at module scope: the ones that assemble a figure import it inside that call,
and an adapter like charts/cost_repo.py, which only shapes rows and hands them to another builder, never imports it
at all -- from __future__ import annotations leaves its go.Figure return annotation unevaluated. Either way every
owner there stays importable without the optional dashboard dependency group. The usage family's own shaping sits
beside those primitives:
orchestrator/observability/dashboard/charts/usage_bands.py holds the four bands a day is counted into, the mode its
stack is switched with, the DailyTokenValues table they are accumulated in, and the roll-up of a TimeSeriesPoint
series into one bucket per day, while orchestrator/observability/dashboard/charts/usage_series.py holds the day span
that roll-up produced, the UsageChartData / UsageAxisRanges shapes it travels in, the completion that gives a day
only the per-backend read saw a bucket of its own, and the height each mode measures a stack by. Above that pair,
orchestrator/observability/dashboard/charts/usage_axis.py holds the step count and pinned height the hero panel is
drawn at, the rounding that raises each maximum to a number the axis divides into equal steps, and the layout the
token and cost scales are assembled in -- both from zero and both cut into the same steps, so one horizontal rule
means something on either scale, with only the token axis drawing the rules -- and
orchestrator/observability/dashboard/charts/usage_traces.py holds what is drawn against them: the shaping that
answers a window holding nothing with no chart at all, the band a stack is added one of at a time, the two modes it is
stacked in, and the cost line overlaid on the secondary axis. Over all four,
orchestrator/observability/dashboard/charts/usage.py is the assembly usage_over_time returns -- the window shaped,
the stack added in the mode the page asked for, the cost line overlaid, and the layout merged last so the token axis
is scaled to the stack that was actually drawn, or the shared placeholder at the same pinned height when the shaping
came back with nothing to draw -- with the backend_per_day stub published beside it. The hero card names that one
owner and the four beneath it are reached through it. No module on that path names
Plotly at module scope, so the whole usage family imports in the default install -- and so
does every other chart family.
The compact table the most-expensive-issues and skill-trigger-rates panels — and the two sortable skill panels named
below — are drawn as lives at observability/dashboard/tables.py: the stylesheet each panel scopes to
itself under its own class, the header and body they are assembled from, and the bar width, short repository name,
missing count, and unpriced amount a cell reports. The most-expensive-issues panel drawn in that table is
observability/dashboard/issue_table.py — its six columns, the rules its in-row bars and status pills are painted
by, and the readings one issue is reduced to and rendered as. The skill-trigger-rates panel beside it is
observability/dashboard/skill_trigger_table.py — its own six columns, the busiest cohort its rate bars are sized
against, and the unknown a category the sink left empty reads as, which the adoption table's and the trigger
matrix's row projections both read off that owner directly. The sparkline drawn inside a KPI
tile is observability/dashboard/sparkline_points.py for where each day of a window sits — its own range, the epsilon
a flat one is floored at, and the window left unprojected — and
observability/dashboard/sparkline_html.py for the polyline, the tint that trace is closed into along the bottom of
the box, the empty box a window with nothing to draw still holds, and the historical
values / color / w / h surface a caller asks for one through. The chrome around that strip — the topbar, the
filter-meta line, the delta pill one tile is annotated
with, and the strip itself — is observability/dashboard/summary_html.py, which reaches the sparkline owner directly
for the line a tile carries. The bar the filter line sits under is observability/dashboard/date_controls.py for
the five slots it is laid
out across, the label naming it, and the presets it offers inline, and
observability/dashboard/date_filter.py for the window a preset opens its pickers on, the inclusive days they hand
back, and the bar assembling all of it; the page pipeline calls the bar on its
owner, so a test intercepting it patches date_filter. The band that bar sits in and the load the choices made there
open is observability/dashboard/page_controls.py — the sidebar a run is narrowed in and the selections it answers
with, the offset that run's timestamps are displayed against, the filters those selections normalize into, the
controls the band is read back as, and the staged plan beneath it; the app prepares its page on that owner, so a test
intercepting it patches page_controls.
Beside them, the insight banners, per-card header, and reliability-tile strip are built by
observability/dashboard/card_html.py, and the backend-efficiency cards and cost-source coverage bar by
observability/dashboard/backend_card.py and
observability/dashboard/coverage_card.py; the primary per-session skill-adoption table is
observability/dashboard/skill_adoption_columns.py, skill_adoption_sort.py, skill_adoption_headers.py,
skill_adoption_rows.py, and skill_adoption.py — its ten columns and the adopt_sort / adopt_dir pair its
headings write, the parse and the two orders behind a click, the header row those clicks come from, what one cell
says, and the sorted panel with the notice a window carrying no session evidence renders instead. The
invocation-level per-skill trigger matrix is
observability/dashboard/skill_matrix_columns.py, skill_matrix_sort.py, skill_matrix_headers.py,
skill_matrix_rows.py, and skill_matrix.py — its eight columns and the mtx_sort / mtx_dir pair its headings
write, the parse and the two orders behind a click, the header row those clicks come from, what one cell says, and
the sorted panel with the notice a window carrying no catalog-backed cell renders instead. The two cards those
tables are reported on are
observability/dashboard/skill_panel.py — the adoption card, the invocation views it opens on, the per-level sections
adoption is folded into beneath them, and the caption qualifying a window nobody adopted anything in — and
observability/dashboard/skill_trigger_panel.py — the
trigger-rate card the section led with before adoption did, and its own fold-out matrix. The listing under all four
panels is
observability/dashboard/recent_runs.py — the columns one run
is scanned by, the offset its timestamp is read on, the collapsed expander it is drawn inside, and the notice a window
with no agent_exit row renders instead — and the trace under it is observability/dashboard/drilldown.py — the one
page read issued outside the cached wrappers, the columns one event is traced in, and the notices a number typed
before a repository, an empty window, and a failed read are answered with. The historical call
shape that trace is still reachable under is observability/dashboard/drilldown_request.py. The three sections that
listing's window is
compared across are observability/dashboard/stage_cost_panel.py — the paired lifecycle bars, the 7:5 columns they
are laid out in, and the one height both figures are pinned to together with the row and base measurement behind it —
observability/dashboard/issue_cost_panel.py — the window's costliest issues ranked beside one efficiency card
per backend, the coverage bar closing that column, and the notice those cards answer a window with no run with —
and observability/dashboard/reliability_panel.py — the window's spend by repository beside the six run-health tiles
and the per-day strip of the issues its runs resolved, bounded by the last day the window covers rather than by its
half-open end. The card closing that run is observability/dashboard/activity_panel.py — the weekday-by-hour grid a
window's tokens are laid out on, the zone its hours are headed and annotated in, and the selectbox picking that zone,
keyed under the name the page reads the offset back off. The card above all of them is
observability/dashboard/usage_panel.py — the header it is titled by, the two-value toggle deciding what a day's
tokens are stacked by, the session key that mode survives a rerun in, and the per-day per-backend totals the second
stack is drawn from. What is drawn where none of those panels can be is
observability/dashboard/page_states.py — the startup state an un-ingested database is answered with and stopped on,
the notice a window matching no row renders together with the load line its skipped second wave would have carried
and the hand-off to the trace beneath, and the footer a page that did draw closes on.
What draws every one of those, and in what order, is three owners. observability/dashboard/page_pipeline.py holds
what the page puts on screen between its two read waves — the banner and filter line written back into the slots the
controls left, the banners a window is worth interrupting the page for, the four-tile strip beneath them, and the
staged load all three are drawn inside, whose first pass reports nothing back on a window that matched no row and is
therefore what ends a load before the second wave is paid for. observability/dashboard/chart_sections.py holds the
five cards a figure is drawn on in the order the page stacks them, and observability/dashboard/page_sections.py the
four panels beneath those cards together with the single call the whole second wave is drawn by. The app opens its
load and draws its wave on those owners, so a test intercepting either patches
page_pipeline or page_sections.
What a whole render of that page is threaded
through is observability/dashboard/page_models.py — the seven frozen shapes, with the issue scope and window span
read off the filters among them — and the Plotly configuration every
figure it draws is handed is observability/dashboard/render_config.py.
Theme. The plotly-free theme lives under orchestrator/observability/dashboard/, split by what a value is.
palette.py holds the chrome colors (cool gray #f4f5f8 page, white cards, indigo accent, muted ink tints), the
semantic trio the delta pills and insight banners are tinted from, the per-token-type / per-backend / per-agent-role /
per-review-round / per-stage / per-cost_source maps, and the color_for(...) fallback a value no map covers resolves
through. tokens.py holds the spacing tokens, the 1480px content max-width, and the IBM Plex Sans / Mono stacks.
layout.py builds the shared base_layout(title=...) Plotly dict; css.py interpolates both token owners into the
PAGE_CSS string the dashboard injects through st.markdown(unsafe_allow_html=True); and formatting.py holds the
fmt_money / fmt_money_exact / fmt_tokens / fmt_num formatters. theme.py is the sixth owner beside them and
implements nothing: it reads all five back under one name, which is the object the analytics app hands every panel it
draws. Every value on it is the style owner's own.
.streamlit/config.toml mirrors the palette into Streamlit's [theme] and disables the [browser]
gatherUsageStats POST so the launch stays local-observability-only.
Independence. The dashboard process is independent of the polling tick: it does not open a GitHub session, does not
write to Postgres, and can be deployed off-host by repointing ANALYTICS_DB_URL at a managed Postgres endpoint without
changing the orchestrator's deployment.
Empty and error states¶
The dashboard never raises an unhandled exception at the user — every missing-data or misconfiguration case surfaces as a labeled banner.
`ANALYTICS_DB_URL` is not configured. …(top-levelst.warning, app stops) — env —ANALYTICS_DB_URLis unset, empty, or set tooff/disabled/none. Set it in the developer checkout's.envand relaunch the dashboard using the developer instructions (the URL is parsed once, when the analytics settings holder is first imported, so a browser reload alone will not pick up the new value).Could not load analytics filter options: …(top-levelst.error, app stops) — DB connectivity — The dashboard could not reach Postgres at startup. Confirmdocker compose psshowsanalytics-dbhealthy, that the host / port / credentials inANALYTICS_DB_URLmatchanalytics-db/.env, and that the user can connect withpsql.Analytics query failed: …(top-levelst.error, app stops) — DB schema / I/O — A read query raised mid-render. Most commonly theanalytics_eventstable is missing — either the volume is fresh and the init script has not been applied (docker compose down && docker compose up -d) or a manual schema reapply is needed (see Service layout).No analytics events have been recorded yet. …(top-levelst.info, app stops) — data — Theanalytics_eventstable holds zero rows. Confirm the JSONL sink is on (ANALYTICS_LOG_PATH), that recent workflow activity produced records, and run analytics sync to populate Postgres.No analytics events match the current filters.(page banner) — data — The data extent is non-empty but every row was filtered out. Widen the window preset, pickAllfor the repo, blank the issue-number input, and confirm the event / stage multi-selects still have every option selected (an empty multi-select is the documented "show nothing" signal).No stage data matches the current filters.(chart annotation) — data — Scoped to the stage breakdown chart. Also empty when the only matching rows have a NULL stage (stage_evaluationrecords on issues with no workflow label).No `agent_exit` rows match the current filters.— data — The window containsstage_enter/stage_evaluationrows but no agent invocations — surfaces in the review-round chart, backend cards, cost coverage bar, and recent-runs expander.No agent runs with recorded cost in this window.— data — The top-cost issues table fell back to its empty state — no(repo, issue)pair in the window has any priced agent runs.No repos match the current filters.— data — The per-repo activity chart is empty for this filter combination.Pick a specific repo in the sidebar before drilling into an issue number …— UI guard — The issue-number input is inert with the repo filter onAllbecause GitHub issue numbers are not unique across repos.No analytics events recorded for `<repo>#<n>` under the current filters.— data / filter — The drill-down query returned nothing. Either the issue number is wrong for that repo, the orchestrator has not processed it yet, or the event / stage multi-selects exclude every row for that issue.Issue drill-down failed: …— DB I/O — The drill-down query raised but the headline metrics rendered first. Same fixes asAnalytics query failed: ….
If a sidebar multi-select is explicitly cleared (no items selected), every dependent widget falls back to "no data"
— that is the documented "show nothing for this dimension" signal. Re-select the items (or hit the ↺ reset chip
Streamlit renders on the widget) to restore the default unfiltered shape.
If analytics sync runs cleanly (non-zero inserted=) but the dashboard
still shows zero rows, double-check the ANALYTICS_DB_URL the sync used — passing --db-url postgresql://other/db
(or a different shell environment) populates a different database than the one the dashboard is reading.