mistercrunch opened a new pull request, #45862:
URL: https://github.com/apache/superset/pull/45862

   ### 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
   
   ```mermaid
   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>"]
   ```
   
   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:
      ```python
      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](https://github.com/apache/superset/issues/13351))
     - [ ] Migration is atomic, supports rollback & is backwards-compatible
     - [ ] Confirm DB migration upgrade and downgrade tested
     - [ ] Runtime estimates and downtime expectations provided
   - [x] 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](https://claude.com/claude-code)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to