Skip to content

feat(chart-data): emit StatsD timers for each phase of the chart data API - #45862

Draft
mistercrunch wants to merge 3 commits into
apache:masterfrom
mistercrunch:superset-chart-data-api-metrics
Draft

mistercrunch wants to merge 3 commits into
apache:masterfrom
mistercrunch:superset-chart-data-api-metrics

Conversation

@mistercrunch

Copy link
Copy Markdown
Member

SUMMARY

When a dashboard is slow, the first question is always: is the time spent in Superset, or in the database? Today Superset can't answer that from its own metrics.

/api/v1/chart/data is where nearly all dashboard load time goes: every chart on a dashboard makes at least one call to it. The endpoint already has a single StatsD timer (ChartDataRestApi.data.time), but that one number mixes together:

  • authentication and permission checks,
  • building the query context and object-level access checks,
  • results-cache lookups, which are mostly cheap, combined with full database round trips,
  • SQL generation and Jinja rendering,
  • opening a database connection (by default one per query, via NullPool),
  • the database's own queueing and execution time,
  • fetching rows and building DataFrames,
  • pandas post-processing, time comparison queries, and contribution totals,
  • writing the result back to the cache (pickling large frames can be slow),
  • JSON-serializing the response.

Without a breakdown, "the dashboard is slow" turns into guesswork. Nobody can tell whether to tune the warehouse, the cache, a chart's post-processing, or Superset itself. The timer also has no tags, so cache hits, misses and async 202 responses all land in the same distribution, and a fast cache hit can hide a slow query.

This PR adds one StatsD timer per meaningful phase of the request, plus cache outcome counters. Each phase's duration can then be read directly instead of guessed by subtracting overlapping timers.

How a chart data request flows, and where each metric fires

flowchart TD
    A[HTTP request] --> B["@protect: auth + RBAC<br/><code>chart_data.auth</code>"]
    B --> C["Saved chart lookup (GET only)<br/><code>chart_data.chart_lookup</code><br/><code>chart_data.dashboard_filters</code>"]
    C --> D["Build query context<br/><code>chart_data.query_context.build</code>"]
    D --> E["Object-level access checks<br/><code>chart_data.query_context.validate</code>"]
    E -->|async_mode, cache miss| F["Schedule tasks, return 202<br/><code>chart_data.async.submit</code>"]
    E -->|sync| G["Run all queries<br/><code>chart_data.command.execute</code>"]
    G --> T["Contribution totals (if needed)<br/><code>chart_data.totals</code>"]
    G --> Q["Per query: <code>chart_data.query.total</code>"]
    Q --> Q1["Plan + cache key<br/><code>chart_data.query.planning</code><br/><code>chart_data.cache.key</code>"]
    Q1 --> Q2["Cache read<br/><code>chart_data.cache.get</code><br/><code>chart_data.cache.hit | miss | bypass</code>"]
    Q2 -->|hit| Q5
    Q2 -->|miss or bypass| Q3["Datasource query<br/><code>chart_data.query.data_acquisition</code>"]
    Q3 --> S1["SQL generation<br/><code>datasource.query.sql_generation</code>"]
    S1 --> S2["<code>database.connect.&lt;engine&gt;</code><br/><code>database.execute.&lt;engine&gt;</code><br/><code>database.fetch.&lt;engine&gt;</code><br/><code>database.dataframe.&lt;engine&gt;</code>"]
    S2 --> S3["<code>datasource.query.normalize</code><br/><code>datasource.query.time_offsets</code><br/><code>datasource.query.post_processing</code>"]
    S3 --> Q4["Annotations + cache write<br/><code>chart_data.query.annotations</code><br/><code>chart_data.cache.set</code>"]
    Q4 --> Q5["Payload assembly<br/><code>chart_data.query.payload_assembly</code>"]
    Q5 --> R["JSON response<br/><code>chart_data.response.serialize</code>"]
Loading

The async path (GLOBAL_ASYNC_QUERIES) runs the same per-query code in the Celery worker. All chart_data.query.*, chart_data.cache.*, datasource.* and database.* metrics are emitted from the worker too, so async dashboards get the same breakdown.

Metrics added

All timers are in milliseconds and measured with a monotonic clock (perf_counter_ns).

Metric Type What it measures
chart_data.auth timer @protect: authentication + permission check, before the handler runs
chart_data.chart_lookup timer Loading the saved chart (GET /chart/<pk>/data/ only)
chart_data.dashboard_filters timer Resolving dashboard filter context (filters_dashboard_id only)
chart_data.query_context.build timer Request → QueryContext, including loading the datasource
chart_data.query_context.validate timer Object-level raise_for_access checks
chart_data.async.submit timer Scheduling async tasks (requests that return 202)
chart_data.command.execute timer All queries of a synchronous request
chart_data.totals timer Contribution totals query, only when a chart needs one
chart_data.query.total timer One query, end to end
chart_data.query.planning timer Query validation, cache key, force resolution
chart_data.cache.key timer Computing the results cache key (RLS + extra cache keys / Jinja)
chart_data.cache.get timer Cache backend read + deserialization (not emitted on bypass)
chart_data.cache.hit / .miss / .bypass counter Results cache outcome. bypass = forced refresh or cache disabled
chart_data.query.data_acquisition timer Everything spent getting data from the datasource on a miss or bypass
chart_data.query.annotations timer Annotation layer data, on a miss or bypass
chart_data.cache.set timer Pickling and writing a fresh result to the cache (actual writes only)
chart_data.query.payload_assembly timer Building the per-query payload
chart_data.response.serialize timer JSON-encoding the response body
datasource.query.sql_generation timer Building and rendering SQL
datasource.query.normalize timer Normalizing temporal/metric columns
datasource.query.time_offsets timer Time comparison queries and joins
datasource.query.post_processing timer Pandas post-processing (only when operations exist)
database.connect.<engine> timer Opening a DB connection
database.execute.<engine> timer Driver cursor.execute() for one statement. For drivers whose execute blocks until completion, this covers the database's queueing, compilation and execution, plus network
database.fetch.<engine> timer Fetching result rows from the driver
database.dataframe.<engine> timer Building the DataFrame from fetched rows

Questions these metrics answer

  • Superset or the database? Compare database.execute.<engine> + database.fetch.<engine> against chart_data.query.total and the endpoint timer.
  • Is the cache helping? Look at the hit/miss/bypass rates, at chart_data.cache.get (is reading the cache itself slow?), and at chart_data.cache.set (are large result writes delaying responses?).
  • Is connection setup expensive? database.connect.<engine> shows this. It matters with NullPool and engines whose authentication handshake is costly.
  • Is post-processing or serialization the bottleneck? See datasource.query.post_processing, datasource.query.time_offsets and chart_data.response.serialize.
  • Is auth slow? See chart_data.auth and chart_data.query_context.validate.

Design decisions

  • Static dot-notation keys, no tags. BaseStatsLogger has no tag support, so any dimension has to live in the key. The only dimension added is the DB engine name (db_engine_spec.engine), which comes from a small fixed set and is normalized to one key segment. No chart, dashboard, user, database or tenant IDs, no SQL, and no cache keys appear in metric names. That keeps cardinality bounded and avoids leaking anything sensitive.
  • Namespaces follow the layer that owns the code. chart_data.* is the chart data API and query context processor. datasource.* and database.* are shared query execution code, so they are honestly named for what they measure and will also cover other features that use the same path.
  • Nested, not additive. Phases nest; for example, database.execute is inside chart_data.query.data_acquisition, which is inside chart_data.query.total. Compare within one level; don't sum across levels.
  • Avoid noise from near-zero samples. cache.get is skipped on bypass, data_acquisition and annotations only fire when the datasource is actually queried, cache.set only fires on real writes, and totals and post_processing only fire when there is work to do.
  • Best-effort. Every emission swallows metrics-backend errors, so instrumentation can never change a response. Timers also emit when the measured block raises, so slow failures such as timeouts stay visible.
  • Auth timing without touching FAB. Two small decorators placed around @protect() mark entry and handler start. They use functools.wraps, so FAB's _permission_name, _urls and OpenAPI docstrings are kept.
  • Cost. About 15–25 UDP sends per uncached query when StatsD is configured. The default DummyStatsLogger only debug-logs.

Existing telemetry, for context

  • ChartDataRestApi.{data,get_data}.time and the .success/.warning/.error counters are unchanged.
  • loading_from_cache, loaded_from_cache, loaded_from_source and set_cache_key are unchanged. The new chart_data.cache.* counters add an explicit miss and bypass signal.
  • CHART_DATA_INCLUDE_TIMING (per-query timing in the response body) is unchanged; these metrics are its aggregate counterpart. The execute_sql event-log entry is also unchanged.

Out of scope (possible follow-ups)

  • Time a request spends waiting for a WSGI worker. That happens before Flask and is best measured at the load balancer or gunicorn.
  • Per-request correlation IDs, and capturing warehouse query IDs in logs so they can be joined with the database's query history.
  • Splitting the database's own queue/compile/execute time. That data lives in the warehouse's query history, not in the driver call.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A (backend metrics only).

TESTING INSTRUCTIONS

  1. Point Superset at a StatsD sink, e.g. nc -ul 8125 or a local statsd/Datadog agent:
    from superset.stats_logger import StatsdStatsLogger
    STATS_LOGGER = StatsdStatsLogger(host="localhost", port=8125, prefix="superset")
    Or keep the default DummyStatsLogger and set the log level to DEBUG to see [stats_logger] (timing) ... lines.
  2. Load a dashboard. You should see superset.chart_data.*, superset.datasource.query.* and superset.database.*.<engine> timers, and a chart_data.cache.miss per query.
  3. Reload. Cached charts emit chart_data.cache.hit and chart_data.cache.get, but no database.* timers.
  4. Force-refresh a chart. It emits chart_data.cache.bypass and no chart_data.cache.get.

Unit tests cover the helpers (ms conversion, failures swallowed, timing on exceptions, key normalization, the auth decorator pair), the cache hit/miss/bypass outcomes and per-phase timers in the processor, cache-write timing for actual writes only, and the ordered database.connect/execute/fetch/dataframe timers in Database.get_df.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

Docs: new "Chart data latency metrics" section in docs/admin_docs/configuration/event-logging.mdx.

🤖 Generated with Claude Code

… API

Break /api/v1/chart/data latency down by phase so slow dashboards can be
attributed to auth, query context construction, the results cache, SQL
generation, database connect/execute/fetch, post-processing, or response
serialization. Adds best-effort, monotonic-clock helpers in
superset/utils/timing_metrics.py and documents every key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added api Related to the REST API doc Namespace | Anything related to documentation labels Oct 29, 2026
@netlify

netlify Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit b1c937c
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6ae402e21471a9c64cbd1d2b
😎 Deploy Preview https://deploy-preview-45862--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.72611% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.71%. Comparing base (0181754) to head (b1c937c).
⚠️ Report is 29 commits behind head on master.

Files with missing lines Patch % Lines
superset/utils/timing_metrics.py 96.36% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #45862      +/-   ##
==========================================
+ Coverage   84.69%   84.71%   +0.02%     
==========================================
  Files        3069     3070       +1     
  Lines      205494   205784     +290     
  Branches    48314    48344      +30     
==========================================
+ Hits       174038   174330     +292     
+ Misses      28165    28158       -7     
- Partials     3291     3296       +5     
Flag Coverage Δ
hive 34.51% <33.12%> (-0.02%) ⬇️
mysql 52.63% <90.44%> (+0.08%) ⬆️
postgres 52.63% <90.44%> (+0.08%) ⬆️
presto 36.29% <82.16%> (+0.03%) ⬆️
python 87.76% <98.72%> (+0.03%) ⬆️
sqlite 52.40% <90.44%> (+0.08%) ⬆️
unit 82.50% <97.45%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rebenitez1802 rebenitez1802 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve: no regressions — responses, permissions, OpenAPI and every existing metric match master end to end — but four of the new timers misreport what the docs say they measure; small, validated fixes below and inline (non-blocking).

🟡 Medium — chart_data.query.total / payload_assembly leave out result materialization
Both are emitted at the end of get_df_payload_result (superset/common/query_context_processor.py:513-517), but _materialize_full_payload (superset/common/query_actions.py:217: records/CSV/XLSX conversion, dtype extraction, the AUTO-currency query) runs after that, untimed. On a 100k-row table chart StatsD reports payload_assembly 0.6 ms and total 657 ms, while the CHART_DATA_INCLUDE_TIMING sidecar — described as their counterpart — reports 306 ms and 963 ms. Fix: wrap the call at query_actions.py:217 in timed("chart_data.query.materialize") and add a docs row (then total 714 + materialize 281 ≈ sidecar 995 ms), or emit both timers from the QueryTiming built in get_query_results_with_timing.

🟡 Medium — chart_data.cache.set times writes that never happen
timed("chart_data.cache.set") (superset/common/utils/query_cache_manager.py:161) also fires when set_and_log_cache returns early — the default NullCache and a -1 timeout — so every query on a default install adds a ~0.01 ms sample, contrary to "actual writes only". test_cache_set_timed_only_for_cache_writes codifies this: its success case runs against the unit-test NullCache and still expects a sample. Fix: time only attempted writes (timeout != CACHE_DISABLED_TIMEOUT and not isinstance(_cache[region].cache, NullCache), else nullcontext()); gating on result_persisted instead would also hide slow failed writes.

Comment on lines +595 to +596
timed("chart_data.response.serialize"),
event_logger.log_context(f"{self.__class__.__name__}.json_dumps"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium — chart_data.response.serialize includes the event-log DB write
timed() is the outer context manager here, so it also covers the exit of event_logger.log_context, which with the default DBEventLogger is a synchronous INSERT + COMMIT to the metadata DB. With a 50 ms metadata write the timer reads ~52–55 ms while json.dumps takes ~0.03 ms (database.execute already nests correctly inside log_context("execute_sql")). Swapping the order fixes it:

Suggested change
timed("chart_data.response.serialize"),
event_logger.log_context(f"{self.__class__.__name__}.json_dumps"),
event_logger.log_context(f"{self.__class__.__name__}.json_dumps"),
timed("chart_data.response.serialize"),

Comment thread superset/models/core.py Outdated
Comment on lines +106 to +112
from superset.utils.timing_metrics import (
elapsed_ms,
emit_timing,
metric_suffix,
now_ns,
timed,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Goes with the database.connect suggestion below, which leaves elapsed_ms, emit_timing and now_ns unused:

Suggested change
from superset.utils.timing_metrics import (
elapsed_ms,
emit_timing,
metric_suffix,
now_ns,
timed,
)
from superset.utils.timing_metrics import metric_suffix, timed

Comment thread superset/models/core.py Outdated
Comment on lines +1042 to +1047
engine_suffix = metric_suffix(self.db_engine_spec.engine)
connect_start_ns = now_ns()
with self.get_raw_connection(catalog=catalog, schema=schema) as conn:
emit_timing(
f"database.connect.{engine_suffix}", elapsed_ms(connect_start_ns)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium — database.connect.<engine> is never emitted when connecting fails
The timer is emitted inside the with body, so a failed or timed-out connect emits nothing: a 2 s postgres connect timeout produced no database.connect.postgresql while chart_data.query.data_acquisition read 2032 ms, contrary to "slow failures such as timeouts stay visible". Timing only the context manager's entry via ExitStack (same pattern as _open_with_oauth2_retry) fixes it; OAuth2 handling for connect and body errors is unchanged, and the rest of the block keeps its indentation. Apply together with the import suggestion at the top of the file.

Suggested change
engine_suffix = metric_suffix(self.db_engine_spec.engine)
connect_start_ns = now_ns()
with self.get_raw_connection(catalog=catalog, schema=schema) as conn:
emit_timing(
f"database.connect.{engine_suffix}", elapsed_ms(connect_start_ns)
)
engine_suffix = metric_suffix(self.db_engine_spec.engine)
with ExitStack() as stack:
with timed(f"database.connect.{engine_suffix}"):
conn = stack.enter_context(
self.get_raw_connection(catalog=catalog, schema=schema)
)

eschutho and others added 2 commits October 30, 2026 02:36
… cache work

- Emit chart_data.query.{total,planning,data_acquisition,payload_assembly}
  even when a phase raises; time database.connect.* for failed attempts.
- Count a missing results-cache backend as cache.bypass, not cache.miss.
- Scope database.dataframe.* to DataFrame construction, excluding the
  mutator and post_process_df.
- Time result materialization as chart_data.query.materialize and document it.
- Only time chart_data.cache.set when a write is attempted (not for
  NullCache or a disabled timeout).

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API doc Namespace | Anything related to documentation review:draft size/XL size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants