mirror of
https://github.com/apache/superset.git
synced 2026-07-12 09:45:43 +00:00
T037 — Instrument the five phases of get_activity with stats_logger timing metrics under the `superset.activity_view.<kind>.<phase>_ms` key convention (plan §D-17). Wrap each phase with a stats_timing context manager: * superset.activity_view.<kind>.relationship_resolution_ms (_resolve_scope) * superset.activity_view.<kind>.fetch_ms (_fetch_change_records) * superset.activity_view.<kind>.visibility_filter_ms * superset.activity_view.<kind>.denormalize_ms * superset.activity_view.<kind>.decorate_ms `<kind>` is the lowercased path-entity model class name (dashboard / slice / sqlatable), enabling per-endpoint-family Grafana panels. T038 — Add request- and response-shape attributes via the existing counter/gauge interface: * superset.activity_view.<kind>.requests.include_<value> (incr) * superset.activity_view.<kind>.requests.has_since_filter_<true|false> (incr) * superset.activity_view.<kind>.page_size (gauge) * superset.activity_view.<kind>.record_count (gauge — post-visibility-filter) * superset.activity_view.<kind>.related_entity_count.charts (gauge) * superset.activity_view.<kind>.related_entity_count.datasets (gauge) Confirmed no PII: entity names, diff content, user identifiers — none flow into the metric layer. Only counts and shape tags. T050 — Cross-coupling sanity test (unit-scope): asserts _METRIC_PREFIX == "superset.activity_view" so a code review catches accidental drift from sc-103156's eventual "superset.versioning.*" sibling namespace. Both endpoint families belong to one Grafana panel under "superset.versioning.* OR superset.activity_view.*". Full suite: 66 unit + 35 integration + 1 xfailed (T044 sc-103156 restore-kind dependency, unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
534 lines
20 KiB
Python
534 lines
20 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
"""Unit tests for ``superset.versioning.activity`` pure helpers (sc-107283).
|
|
|
|
No app context, no DB, no Flask. Covers the helpers that can be exercised
|
|
in isolation: window intersection, scope resolution branching, entity-
|
|
window merging, AV-012 summary headlines, ``changed_by`` projection,
|
|
read-predicate fall-through, and the no-impact paths of
|
|
``_compute_impact``. The DB-touching helpers
|
|
(``_charts_attached_to_dashboard``, ``_datasets_used_by_chart``,
|
|
``_fetch_change_records``, ``_denormalize_entity_names``,
|
|
``_check_entity_tombstones``, ``_lookup_entity_uuids``) are exercised
|
|
by the integration suite in
|
|
``tests/integration_tests/versioning/activity_view_tests.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
import pytest
|
|
|
|
from superset.versioning.activity import (
|
|
_API_KIND_TO_TABLE,
|
|
_build_summary,
|
|
_can_read,
|
|
_changed_by_dict,
|
|
_collect_impact_pairs,
|
|
_DEFAULT_PAGE_SIZE,
|
|
_impact_for_record,
|
|
_intersect_windows,
|
|
_MAX_PAGE_SIZE,
|
|
_merge_entity_windows,
|
|
_resolve_scope,
|
|
_row_within_any_window,
|
|
_TABLE_KIND_TO_API,
|
|
_union_windows,
|
|
ActivityParamsError,
|
|
EntityWindows,
|
|
parse_activity_query_params,
|
|
Window,
|
|
)
|
|
|
|
# ---- _intersect_windows ---------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"outer, inner, expected",
|
|
[
|
|
# Inner fully inside outer
|
|
((10, 20), (15, 18), (15, 18)),
|
|
# Left overlap — clipped on the left
|
|
((10, 20), (5, 15), (10, 15)),
|
|
# Right overlap — clipped on the right
|
|
((10, 20), (15, 25), (15, 20)),
|
|
# Outer fully inside inner
|
|
((10, 20), (5, 25), (10, 20)),
|
|
# Touching at end → half-open semantics yield disjoint
|
|
((10, 20), (20, 30), None),
|
|
# Disjoint to the right
|
|
((10, 20), (25, 30), None),
|
|
# Disjoint to the left
|
|
((10, 20), (0, 5), None),
|
|
# Open-ended outer (end_tx=None means +∞)
|
|
((10, None), (5, 25), (10, 25)),
|
|
# Open-ended inner
|
|
((10, 20), (5, None), (10, 20)),
|
|
# Both open-ended
|
|
((10, None), (5, None), (10, None)),
|
|
# Identical
|
|
((10, 20), (10, 20), (10, 20)),
|
|
],
|
|
)
|
|
def test_intersect_windows(
|
|
outer: Window, inner: Window, expected: Optional[Window]
|
|
) -> None:
|
|
assert _intersect_windows(outer, inner) == expected
|
|
|
|
|
|
# ---- _resolve_scope -------------------------------------------------------
|
|
|
|
|
|
def test_resolve_scope_self_only_for_dashboard() -> None:
|
|
"""``include='self'`` yields exactly one tuple covering all transactions."""
|
|
assert _resolve_scope("Dashboard", 42, "self") == [
|
|
("Dashboard", 42, [(0, None)]),
|
|
]
|
|
|
|
|
|
def test_resolve_scope_self_only_for_chart() -> None:
|
|
assert _resolve_scope("Slice", 7, "self") == [("Slice", 7, [(0, None)])]
|
|
|
|
|
|
def test_resolve_scope_self_only_for_dataset() -> None:
|
|
assert _resolve_scope("SqlaTable", 9, "self") == [
|
|
("SqlaTable", 9, [(0, None)]),
|
|
]
|
|
|
|
|
|
def test_dataset_has_no_related_scope() -> None:
|
|
"""AV-004: datasets are not transitive recipients of activity in V2."""
|
|
assert _resolve_scope("SqlaTable", 9, "related") == []
|
|
|
|
|
|
def test_dataset_all_returns_only_self() -> None:
|
|
"""For datasets, ``include='all'`` == ``include='self'`` (AV-004)."""
|
|
assert _resolve_scope("SqlaTable", 9, "all") == [
|
|
("SqlaTable", 9, [(0, None)]),
|
|
]
|
|
|
|
|
|
# ---- _merge_entity_windows -----------------------------------------------
|
|
|
|
|
|
def test_merge_entity_windows_collapses_repeated_keys() -> None:
|
|
"""Repeated ``(api_kind, entity_id)`` entries union their window lists
|
|
so the fetch query's OR-clause stays compact."""
|
|
merged = _merge_entity_windows(
|
|
[
|
|
("Slice", 1, [(0, 100)]),
|
|
("Slice", 1, [(200, 300)]),
|
|
("SqlaTable", 5, [(0, None)]),
|
|
]
|
|
)
|
|
by_key = {(kind, eid): windows for kind, eid, windows in merged}
|
|
assert by_key[("Slice", 1)] == [(0, 100), (200, 300)]
|
|
assert by_key[("SqlaTable", 5)] == [(0, None)]
|
|
|
|
|
|
def test_merge_entity_windows_preserves_singletons() -> None:
|
|
"""Non-duplicated entries pass through unchanged."""
|
|
inputs: list[EntityWindows] = [
|
|
("Slice", 1, [(0, 100)]),
|
|
("Dashboard", 2, [(10, 20)]),
|
|
]
|
|
merged = _merge_entity_windows(inputs)
|
|
assert sorted(merged) == sorted(inputs)
|
|
|
|
|
|
def test_merge_entity_windows_unions_overlapping_windows_for_one_entity() -> None:
|
|
"""Same entity, many redundant attachment windows → collapsed to one.
|
|
|
|
This guards the SQLite expression-tree limit: a fixture that
|
|
re-creates a chart-on-dashboard association across many transactions
|
|
used to produce N separate OR branches in the fetch query (one per
|
|
redundant window). _merge_entity_windows must coalesce them.
|
|
"""
|
|
scope: list[EntityWindows] = [
|
|
("Slice", 1, [(10, 20)]),
|
|
("Slice", 1, [(15, 25)]), # overlaps
|
|
("Slice", 1, [(25, 30)]), # touches
|
|
("Slice", 1, [(40, 50)]), # disjoint
|
|
]
|
|
merged = _merge_entity_windows(scope)
|
|
assert merged == [("Slice", 1, [(10, 30), (40, 50)])]
|
|
|
|
|
|
# ---- _union_windows -------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"windows, expected",
|
|
[
|
|
# Disjoint windows pass through
|
|
([(10, 20), (30, 40)], [(10, 20), (30, 40)]),
|
|
# Overlapping windows merge
|
|
([(10, 20), (15, 25)], [(10, 25)]),
|
|
# Touching windows merge (half-open: [10,20) + [20,30) = [10,30))
|
|
([(10, 20), (20, 30)], [(10, 30)]),
|
|
# Many overlapping windows collapse to one
|
|
([(10, 20), (15, 25), (20, 30), (25, 35)], [(10, 35)]),
|
|
# Input order doesn't matter
|
|
([(30, 40), (10, 20), (15, 25)], [(10, 25), (30, 40)]),
|
|
# Open-ended absorbs everything to the right
|
|
([(10, None), (50, 60)], [(10, None)]),
|
|
# Open-ended at the right merges into open-ended
|
|
([(10, 20), (15, None)], [(10, None)]),
|
|
# Empty input
|
|
([], []),
|
|
# Single window pass-through
|
|
([(5, 10)], [(5, 10)]),
|
|
],
|
|
)
|
|
def test_union_windows(windows: list[Window], expected: list[Window]) -> None:
|
|
assert _union_windows(windows) == expected
|
|
|
|
|
|
# ---- _row_within_any_window (Python post-filter for the fetch query) ------
|
|
|
|
|
|
def test_row_in_window_inside() -> None:
|
|
assert _row_within_any_window({"transaction_id": 15}, [(10, 20)])
|
|
|
|
|
|
def test_row_in_window_at_start_boundary_inclusive() -> None:
|
|
"""Half-open: ``[10, 20)`` includes 10."""
|
|
assert _row_within_any_window({"transaction_id": 10}, [(10, 20)])
|
|
|
|
|
|
def test_row_in_window_at_end_boundary_exclusive() -> None:
|
|
"""Half-open: ``[10, 20)`` excludes 20."""
|
|
assert not _row_within_any_window({"transaction_id": 20}, [(10, 20)])
|
|
|
|
|
|
def test_row_in_open_ended_window() -> None:
|
|
"""``end=None`` means +∞."""
|
|
assert _row_within_any_window({"transaction_id": 999}, [(10, None)])
|
|
|
|
|
|
def test_row_in_any_of_several_windows() -> None:
|
|
assert _row_within_any_window(
|
|
{"transaction_id": 50}, [(10, 20), (40, 60), (90, 100)]
|
|
)
|
|
|
|
|
|
def test_row_in_no_windows_returns_false() -> None:
|
|
assert not _row_within_any_window({"transaction_id": 50}, [])
|
|
assert not _row_within_any_window({"transaction_id": 25}, [(10, 20), (30, 40)])
|
|
|
|
|
|
# ---- Kind translation round-trip -----------------------------------------
|
|
|
|
|
|
def test_kind_translation_is_bijective_for_supported_kinds() -> None:
|
|
"""Every API kind maps to a table kind and back to the same value.
|
|
Locks in the contract that the two maps don't drift."""
|
|
for api_kind, table_kind in _API_KIND_TO_TABLE.items():
|
|
assert _TABLE_KIND_TO_API[table_kind] == api_kind
|
|
|
|
|
|
# ---- _build_summary (AV-012) ---------------------------------------------
|
|
|
|
|
|
def test_summary_for_dataset_column_change() -> None:
|
|
rec = {"kind": "column", "entity_name": "Sales Transactions"}
|
|
assert _build_summary("SqlaTable", rec) == (
|
|
"Dataset column changed: Sales Transactions"
|
|
)
|
|
|
|
|
|
def test_summary_for_chart_filter_change() -> None:
|
|
rec = {"kind": "filter", "entity_name": "Top Charts"}
|
|
assert _build_summary("Slice", rec) == "Chart filter changed: Top Charts"
|
|
|
|
|
|
def test_summary_for_restore_event() -> None:
|
|
rec = {"kind": "restore", "entity_name": "Q4 Dashboard"}
|
|
assert _build_summary("Dashboard", rec) == "Dashboard restored: Q4 Dashboard"
|
|
|
|
|
|
def test_summary_unknown_kind_falls_back_to_updated() -> None:
|
|
"""Unmapped change kinds collapse to a generic 'updated' verb."""
|
|
rec = {"kind": "mystery_kind", "entity_name": "X"}
|
|
assert _build_summary("Dashboard", rec) == "Dashboard updated: X"
|
|
|
|
|
|
def test_summary_without_entity_name_drops_colon() -> None:
|
|
"""Tombstoned entities have no name; the headline reads naturally
|
|
without a trailing colon and empty value."""
|
|
rec = {"kind": "filter", "entity_name": ""}
|
|
assert _build_summary("Slice", rec) == "Chart filter changed"
|
|
|
|
|
|
# ---- _changed_by_dict ----------------------------------------------------
|
|
|
|
|
|
def test_changed_by_returns_none_when_no_user_attached() -> None:
|
|
"""Saves from CLI/Celery/import have no Flask user (sc-103156 §Session
|
|
2026-05-18 clarification)."""
|
|
assert _changed_by_dict({"changed_by_id": None}) is None
|
|
|
|
|
|
def test_changed_by_projects_only_display_fields() -> None:
|
|
"""Per the ActivityChangedBy contract: id + first_name + last_name only.
|
|
Username is intentionally omitted (data-model.md)."""
|
|
record = {
|
|
"changed_by_id": 5,
|
|
"first_name": "Mike",
|
|
"last_name": "Bridge",
|
|
"user_id": 5, # internal column, must not leak
|
|
}
|
|
result = _changed_by_dict(record)
|
|
assert result == {"id": 5, "first_name": "Mike", "last_name": "Bridge"}
|
|
assert result is not None
|
|
assert "username" not in result
|
|
|
|
|
|
# ---- _can_read fallthrough -----------------------------------------------
|
|
|
|
|
|
def test_can_read_returns_true_for_unsupported_kind() -> None:
|
|
"""Unknown kinds aren't subject to the per-kind security predicate,
|
|
so they pass through (defensive default; tombstones land here too)."""
|
|
|
|
class _StubSecurityManager:
|
|
pass
|
|
|
|
assert _can_read("UnknownKind", object(), _StubSecurityManager()) is True
|
|
|
|
|
|
# ---- _impact_for_record (pure, post-batch) -------------------------------
|
|
|
|
|
|
def test_impact_for_record_dashboard_path_dataset_related_uses_count() -> None:
|
|
"""The only path/related shape that carries impact: ``Dashboard`` →
|
|
``SqlaTable``. The count comes from the pre-batched lookup."""
|
|
record = {"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100}
|
|
counts = {(5, 100): 3}
|
|
assert _impact_for_record(record, "Dashboard", counts) == {"charts": 3}
|
|
|
|
|
|
def test_impact_for_record_missing_count_yields_none() -> None:
|
|
"""A pair the batch query didn't return (no matching siblings)
|
|
collapses to ``None`` rather than ``{"charts": 0}``."""
|
|
record = {"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100}
|
|
assert _impact_for_record(record, "Dashboard", {}) is None
|
|
|
|
|
|
def test_impact_for_record_zero_count_yields_none() -> None:
|
|
"""Explicit zero in the counts map is treated the same as missing —
|
|
no impact field on the wire."""
|
|
record = {"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100}
|
|
assert _impact_for_record(record, "Dashboard", {(5, 100): 0}) is None
|
|
|
|
|
|
def test_impact_for_record_dashboard_path_chart_related_yields_none() -> None:
|
|
"""Dashboard → chart is a direct dependency; no further sibling
|
|
layer to count."""
|
|
record = {"entity_kind": "chart", "entity_id": 5, "transaction_id": 100}
|
|
assert _impact_for_record(record, "Dashboard", {(5, 100): 999}) is None
|
|
|
|
|
|
def test_impact_for_record_chart_path_with_dataset_related_yields_none() -> None:
|
|
"""Chart → dataset: the chart is itself the only dependent of the
|
|
dataset edit."""
|
|
record = {"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100}
|
|
assert _impact_for_record(record, "Slice", {(5, 100): 999}) is None
|
|
|
|
|
|
def test_impact_for_record_dataset_path_yields_none() -> None:
|
|
"""Datasets have no transitive layer (AV-004)."""
|
|
record = {"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100}
|
|
assert _impact_for_record(record, "SqlaTable", {(5, 100): 999}) is None
|
|
|
|
|
|
# ---- _collect_impact_pairs -----------------------------------------------
|
|
|
|
|
|
def test_collect_impact_pairs_dashboard_path_collects_only_datasets() -> None:
|
|
"""The batched pre-query only needs ``(dataset_id, tx)`` pairs.
|
|
Chart-related and self records aren't relevant."""
|
|
records = [
|
|
{"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100},
|
|
{"entity_kind": "dataset", "entity_id": 7, "transaction_id": 200},
|
|
{"entity_kind": "chart", "entity_id": 9, "transaction_id": 300},
|
|
{"entity_kind": "dashboard", "entity_id": 1, "transaction_id": 400},
|
|
]
|
|
assert _collect_impact_pairs(records, "Dashboard") == {(5, 100), (7, 200)}
|
|
|
|
|
|
def test_collect_impact_pairs_dedupes_repeated_pairs() -> None:
|
|
"""Multiple change records for the same (dataset, tx) collapse to
|
|
one pair — the batch query computes the count once."""
|
|
records = [
|
|
{"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100},
|
|
{"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100},
|
|
{"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100},
|
|
]
|
|
pairs = _collect_impact_pairs(records, "Dashboard")
|
|
assert pairs == {(5, 100)}
|
|
|
|
|
|
def test_collect_impact_pairs_chart_path_returns_empty() -> None:
|
|
"""Chart paths have no dashboard layer to count siblings on, so the
|
|
batch never needs to fire."""
|
|
records = [
|
|
{"entity_kind": "dataset", "entity_id": 5, "transaction_id": 100},
|
|
]
|
|
assert _collect_impact_pairs(records, "Slice") == set()
|
|
|
|
|
|
def test_collect_impact_pairs_dataset_path_returns_empty() -> None:
|
|
records = [
|
|
{"entity_kind": "chart", "entity_id": 5, "transaction_id": 100},
|
|
]
|
|
assert _collect_impact_pairs(records, "SqlaTable") == set()
|
|
|
|
|
|
def test_collect_impact_pairs_empty_records_returns_empty() -> None:
|
|
assert _collect_impact_pairs([], "Dashboard") == set()
|
|
|
|
|
|
# ---- parse_activity_query_params (shared API helper) ---------------------
|
|
|
|
|
|
def test_parser_defaults_when_empty() -> None:
|
|
"""No params → ``include='all'``, ``page=0``, ``page_size=DEFAULT``."""
|
|
assert parse_activity_query_params({}) == {
|
|
"include": "all",
|
|
"page": 0,
|
|
"page_size": _DEFAULT_PAGE_SIZE,
|
|
}
|
|
|
|
|
|
def test_parser_clamps_page_size_to_max() -> None:
|
|
"""A request for more than the contract maximum is clamped, not 400'd
|
|
(silent clamp matches AV-019's bounded-payload guarantee)."""
|
|
params = parse_activity_query_params({"page_size": str(_MAX_PAGE_SIZE * 5)})
|
|
assert params["page_size"] == _MAX_PAGE_SIZE
|
|
|
|
|
|
def test_parser_accepts_iso_datetime_with_z_suffix() -> None:
|
|
"""Python <3.11 fromisoformat rejects 'Z'; the parser tolerates it."""
|
|
params = parse_activity_query_params({"since": "2026-01-01T00:00:00Z"})
|
|
assert params["since"].year == 2026
|
|
|
|
|
|
def test_parser_rejects_invalid_include() -> None:
|
|
with pytest.raises(ActivityParamsError, match="include"):
|
|
parse_activity_query_params({"include": "sibling"})
|
|
|
|
|
|
def test_parser_rejects_malformed_datetime() -> None:
|
|
with pytest.raises(ActivityParamsError, match="since"):
|
|
parse_activity_query_params({"since": "yesterday"})
|
|
|
|
|
|
def test_parser_rejects_negative_page() -> None:
|
|
with pytest.raises(ActivityParamsError, match="page"):
|
|
parse_activity_query_params({"page": "-1"})
|
|
|
|
|
|
def test_parser_rejects_zero_page_size() -> None:
|
|
with pytest.raises(ActivityParamsError, match="page_size"):
|
|
parse_activity_query_params({"page_size": "0"})
|
|
|
|
|
|
def test_parser_error_is_a_value_error() -> None:
|
|
"""``ActivityParamsError`` subclasses ``ValueError`` so callers that
|
|
only know about the standard library exception hierarchy still catch
|
|
it correctly."""
|
|
with pytest.raises(ValueError, match="include"):
|
|
parse_activity_query_params({"include": "nope"})
|
|
|
|
|
|
# ---- _can_read per-kind dispatch -----------------------------------------
|
|
|
|
|
|
class _StubSM:
|
|
"""Stand-in for ``security_manager`` exposing only the three
|
|
activity-relevant predicates."""
|
|
|
|
def __init__(
|
|
self,
|
|
dashboard: bool = True,
|
|
chart: bool = True,
|
|
datasource: bool = True,
|
|
) -> None:
|
|
self._dashboard = dashboard
|
|
self._chart = chart
|
|
self._datasource = datasource
|
|
|
|
def can_access_dashboard(self, _entity: Any) -> bool:
|
|
return self._dashboard
|
|
|
|
def can_access_chart(self, _entity: Any) -> bool:
|
|
return self._chart
|
|
|
|
def can_access_datasource(self, _entity: Any) -> bool:
|
|
return self._datasource
|
|
|
|
|
|
def test_can_read_dispatches_to_dashboard_predicate() -> None:
|
|
"""AV-008: Dashboard kind uses ``can_access_dashboard``."""
|
|
assert _can_read("Dashboard", object(), _StubSM(dashboard=True)) is True
|
|
assert _can_read("Dashboard", object(), _StubSM(dashboard=False)) is False
|
|
|
|
|
|
def test_can_read_dispatches_to_chart_predicate() -> None:
|
|
"""T025 / AV-008: a chart record gated by ``can_access_chart``."""
|
|
assert _can_read("Slice", object(), _StubSM(chart=True)) is True
|
|
assert _can_read("Slice", object(), _StubSM(chart=False)) is False
|
|
|
|
|
|
def test_can_read_dispatches_to_datasource_predicate() -> None:
|
|
"""A dataset record is gated by ``can_access_datasource`` — datasources
|
|
are the dataset-and-legacy ``BaseDatasource`` umbrella in the security
|
|
manager, so this is the right predicate for ``SqlaTable``."""
|
|
assert _can_read("SqlaTable", object(), _StubSM(datasource=True)) is True
|
|
assert _can_read("SqlaTable", object(), _StubSM(datasource=False)) is False
|
|
|
|
|
|
# ---- Observability metric-key convention (T050 cross-coupling) ----------
|
|
|
|
|
|
def test_metric_prefix_matches_versioning_namespace_convention() -> None:
|
|
"""T050: cross-coupling sanity. The activity-view's instrumentation
|
|
prefix (``superset.activity_view.*``) must be a sibling of sc-103156's
|
|
eventual ``superset.versioning.*`` namespace, not nested under
|
|
a different root. Both endpoint families belong to the versioning
|
|
feature; their metrics should be discoverable from one Grafana
|
|
filter (``superset.activity_view.*`` OR ``superset.versioning.*``).
|
|
|
|
Locking the prefix in a test catches accidental drift in a code
|
|
review — a future PR renaming the prefix would fail this assertion
|
|
and require explicit acknowledgement.
|
|
"""
|
|
from superset.versioning.activity import _METRIC_PREFIX
|
|
|
|
assert _METRIC_PREFIX == "superset.activity_view", (
|
|
f"Activity-view metrics prefix changed from "
|
|
f"'superset.activity_view' to {_METRIC_PREFIX!r}. If this was "
|
|
"intentional, update sc-103156's FR-027 instrumentation to "
|
|
"match the new convention OR document the new naming in plan §D-17."
|
|
)
|
|
# Sibling-namespace check: starts with the versioning-feature root.
|
|
assert _METRIC_PREFIX.startswith("superset."), (
|
|
"All Superset metrics live under 'superset.*'; activity_view must too."
|
|
)
|