Repository: dbt-labs/dbt-core

Issue:
[CT-2707] Derived metrics throw an error when using the run based semantic manifest.
### Is this a new bug in dbt-core?

- [X] I believe this is a new bug in dbt-core
- [X] I have searched the existing issues, and I could not find an existing issue for this bug

### Current Behavior

I'm getting this error when querying a derived metric: `ins.ValueError: Unknown metric: MetricReference(element_name='order_gross_profit') in element set`. I noticed that the measures list in the semantic manifest is empty, which is likely causing the error: 

@QMalcolm per our conversation today in standup we need to update the way we populate measures. 
```
 {
            "name": "order_gross_profit",
            "description": "Gross profit from each order.",
            "type": "derived",
            "type_params": {
                "measure": null,
                "measures": [],
                "numerator": null,
                "denominator": null,
                "expr": "revenue - cost",
                "window": null,
                "grain_to_date": null,
                "metrics": [
                    {
                        "name": "food_order_amount",
                        "filter": null,
                        "alias": "revenue",
                        "offset_window": null,
                        "offset_to_grain": null
                    },
                    {
                        "name": "order_amount",
                        "filter": null,
                        "alias": "cost",
                        "offset_window": null,
                        "offset_to_grain": null
                    }
                ]
            },
            "filter": null,
            "metadata": null
        },
```

### Expected Behavior

I should be able to query derived metrics from the run based manifest. 

### Steps To Reproduce

1. Create a derived metric like the one in jaffle-sl-template: https://github.com/dbt-labs/jaffle-sl-template/blob/d7ce5ba300bbe809a9d36578912c36d1379175f5/models/metrics/example_metrics.yaml#L48
2. Kick of a run in dbt cloud
3. Run semantic_layer.query()

### Relevant log output

_No response_

### Environment

```markdown
- OS:
- Python:
- dbt:
```


### Which database adapter are you using with dbt?

_No response_

### Additional Context

_No response_


Discussion:
So what we haven't been doing is properly populating the `measures` attribute of metric type params (additionally `measures` on metric type params because `input_measures` once work for #7898 is merged). The `input_measures` can only get populated once all metrics have gone through initial parsing. This is because of derived metrics. If a derived metric depends on a metric that hasn't been parsed yet, then it won't be able to properly populate the `input_measures`. An example of how the logic for setting `input_measures` plays out can be found in dbt-semantic-interfaces' [add_input_metric_measures.py](https://github.com/dbt-labs/dbt-semantic-interfaces/blob/739fae883f991b92ad6db877223e5068a241947d/dbt_semantic_interfaces/transformations/add_input_metric_measures.py#L35) transformation rule. I imagine the logic we used would be quite similar. If so we should longer term coordinate with the semantic layer team to abstract this logic in dbt-semantic-interfaces to make it more accessible.

Resolving patch:
diff --git a/core/dbt/parser/manifest.py b/core/dbt/parser/manifest.py
--- a/core/dbt/parser/manifest.py
+++ b/core/dbt/parser/manifest.py
@@ -122,6 +122,8 @@
 from dbt.dataclass_schema import StrEnum, dbtClassMixin
 from dbt.plugins import get_plugin_manager
 
+from dbt_semantic_interfaces.enum_extension import assert_values_exhausted
+from dbt_semantic_interfaces.type_enums import MetricType
 
 PARSING_STATE = DbtProcessState("parsing")
 PERF_INFO_FILE_NAME = "perf_info.json"
@@ -1064,16 +1066,15 @@ def process_refs(self, current_project: str, dependencies: Optional[Dict[str, Pr
     # node, and updates 'depends_on.nodes' with the unique id
     def process_metrics(self, config: RuntimeConfig):
         current_project = config.project_name
-        for node in self.manifest.nodes.values():
-            if node.created_at < self.started_at:
-                continue
-            _process_metrics_for_node(self.manifest, current_project, node)
         for metric in self.manifest.metrics.values():
-            # TODO: Can we do this if the metric is derived & depends on
-            # some other metric for its definition? Maybe....
             if metric.created_at < self.started_at:
                 continue
+            _process_metric_node(self.manifest, current_project, metric)
             _process_metrics_for_node(self.manifest, current_project, metric)
+        for node in self.manifest.nodes.values():
+            if node.created_at < self.started_at:
+                continue
+            _process_metrics_for_node(self.manifest, current_project, node)
         for exposure in self.manifest.exposures.values():
             if exposure.created_at < self.started_at:
                 continue
@@ -1439,6 +1440,63 @@ def _process_refs(
         node.depends_on.add_node(target_model_id)
 
 
+def _process_metric_node(
+    manifest: Manifest,
+    current_project: str,
+    metric: Metric,
+) -> None:
+    """Sets a metric's input_measures"""
+
+    # This ensures that if this metrics input_measures have already been set
+    # we skip the work. This could happen either due to recursion or if multiple
+    # metrics derive from another given metric.
+    # NOTE: This does not protect against infinite loops
+    if len(metric.type_params.input_measures) > 0:
+        return
+
+    if metric.type is MetricType.SIMPLE or metric.type is MetricType.CUMULATIVE:
+        assert (
+            metric.type_params.measure is not None
+        ), f"{metric} should have a measure defined, but it does not."
+        metric.type_params.input_measures.append(metric.type_params.measure)
+
+    elif metric.type is MetricType.DERIVED or metric.type is MetricType.RATIO:
+        input_metrics = metric.input_metrics
+        if metric.type is MetricType.RATIO:
+            if metric.type_params.numerator is None or metric.type_params.denominator is None:
+                raise dbt.exceptions.ParsingError(
+                    "Invalid ratio metric. Both a numerator and denominator must be specified",
+                    node=metric,
+                )
+            input_metrics = [metric.type_params.numerator, metric.type_params.denominator]
+
+        for input_metric in input_metrics:
+            target_metric = manifest.resolve_metric(
+                target_metric_name=input_metric.name,
+                target_metric_package=None,
+                current_project=current_project,
+                node_package=metric.package_name,
+            )
+
+            if target_metric is None:
+                raise dbt.exceptions.ParsingError(
+                    f"The metric `{input_metric.name}` does not exist but was referenced.",
+                    node=metric,
+                )
+            elif isinstance(target_metric, Disabled):
+                raise dbt.exceptions.ParsingError(
+                    f"The metric `{input_metric.name}` is disabled and thus cannot be referenced.",
+                    node=metric,
+                )
+
+            _process_metric_node(
+                manifest=manifest, current_project=current_project, metric=target_metric
+            )
+            metric.type_params.input_measures.extend(target_metric.type_params.input_measures)
+    else:
+        assert_values_exhausted(metric.type)
+
+
 def _process_metrics_for_node(
     manifest: Manifest,
     current_project: str,


Test patch:
diff --git a/tests/functional/metrics/fixtures.py b/tests/functional/metrics/fixtures.py
--- a/tests/functional/metrics/fixtures.py
+++ b/tests/functional/metrics/fixtures.py
@@ -50,10 +50,8 @@
     description: "The average tenure per person"
     type: ratio
     type_params:
-      numerator:
-        name: years_tenure
-      denominator:
-        name: people
+      numerator: collective_tenure
+      denominator: number_of_people
 
   - name: average_tenure_plus_one
     label: "Average tenure, plus 1"
@@ -98,6 +96,24 @@
         filter: "{{dimension('loves_dbt')}} is true"
       window: 14 days
 
+  - name: average_tenure
+    label: Average Tenure
+    description: The average tenure of our people
+    type: ratio
+    type_params:
+      numerator: collective_tenure
+      denominator: number_of_people
+
+  - name: average_tenure_minus_people
+    label: Average Tenure minus People
+    description: Well this isn't really useful is it?
+    type: derived
+    type_params:
+      expr: average_tenure - number_of_people
+      metrics:
+        - average_tenure
+        - number_of_people
+
 """
 
 invalid_models_people_metrics_yml = """
diff --git a/tests/functional/metrics/test_metric_configs.py b/tests/functional/metrics/test_metric_configs.py
--- a/tests/functional/metrics/test_metric_configs.py
+++ b/tests/functional/metrics/test_metric_configs.py
@@ -1,7 +1,7 @@
 import pytest
 from hologram import ValidationError
 from dbt.contracts.graph.model_config import MetricConfig
-from dbt.exceptions import CompilationError
+from dbt.exceptions import CompilationError, ParsingError
 from dbt.tests.util import run_dbt, update_config_file, get_manifest
 
 
@@ -36,7 +36,7 @@ def models(self):
     def project_config_update(self):
         return {
             "metrics": {
-                "number_of_people": {
+                "average_tenure_minus_people": {
                     "enabled": True,
                 },
             }
@@ -45,12 +45,12 @@ def project_config_update(self):
     def test_enabled_metric_config_dbt_project(self, project):
         run_dbt(["parse"])
         manifest = get_manifest(project.project_root)
-        assert "metric.test.number_of_people" in manifest.metrics
+        assert "metric.test.average_tenure_minus_people" in manifest.metrics
 
         new_enabled_config = {
             "metrics": {
                 "test": {
-                    "number_of_people": {
+                    "average_tenure_minus_people": {
                         "enabled": False,
                     },
                 }
@@ -59,7 +59,7 @@ def test_enabled_metric_config_dbt_project(self, project):
         update_config_file(new_enabled_config, project.project_root, "dbt_project.yml")
         run_dbt(["parse"])
         manifest = get_manifest(project.project_root)
-        assert "metric.test.number_of_people" not in manifest.metrics
+        assert "metric.test.average_tenure_minus_people" not in manifest.metrics
         assert "metric.test.collective_tenure" in manifest.metrics
 
 
@@ -122,6 +122,8 @@ def test_disabled_metric_ref_model(self, project):
         assert "metric.test.number_of_people" in manifest.metrics
         assert "metric.test.collective_tenure" in manifest.metrics
         assert "model.test.people_metrics" in manifest.nodes
+        assert "metric.test.average_tenure" in manifest.metrics
+        assert "metric.test.average_tenure_minus_people" in manifest.metrics
 
         new_enabled_config = {
             "metrics": {
@@ -129,6 +131,12 @@ def test_disabled_metric_ref_model(self, project):
                     "number_of_people": {
                         "enabled": False,
                     },
+                    "average_tenure_minus_people": {
+                        "enabled": False,
+                    },
+                    "average_tenure": {
+                        "enabled": False,
+                    },
                 }
             }
         }
@@ -152,3 +160,33 @@ def test_invalid_config_metric(self, project):
             run_dbt(["parse"])
         expected_msg = "'True and False' is not of type 'boolean'"
         assert expected_msg in str(excinfo.value)
+
+
+class TestDisabledMetric(MetricConfigTests):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people.sql": models_people_sql,
+            "schema.yml": models_people_metrics_yml,
+        }
+
+    def test_disabling_upstream_metric_errors(self, project):
+        run_dbt(["parse"])  # shouldn't error out yet
+
+        new_enabled_config = {
+            "metrics": {
+                "test": {
+                    "number_of_people": {
+                        "enabled": False,
+                    },
+                }
+            }
+        }
+
+        update_config_file(new_enabled_config, project.project_root, "dbt_project.yml")
+        with pytest.raises(ParsingError) as excinfo:
+            run_dbt(["parse"])
+            expected_msg = (
+                "The metric `number_of_people` is disabled and thus cannot be referenced."
+            )
+            assert expected_msg in str(excinfo.value)
diff --git a/tests/functional/metrics/test_metrics.py b/tests/functional/metrics/test_metrics.py
--- a/tests/functional/metrics/test_metrics.py
+++ b/tests/functional/metrics/test_metrics.py
@@ -1,7 +1,9 @@
 import pytest
 
-from dbt.tests.util import run_dbt, get_manifest
+from dbt.cli.main import dbtRunner
+from dbt.contracts.graph.manifest import Manifest
 from dbt.exceptions import ParsingError
+from dbt.tests.util import run_dbt, get_manifest
 
 
 from tests.functional.metrics.fixtures import (
@@ -35,18 +37,40 @@ def test_simple_metric(
         self,
         project,
     ):
-        # initial run
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        runner = dbtRunner()
+        result = runner.invoke(["parse"])
+        assert result.success
+        assert isinstance(result.result, Manifest)
         manifest = get_manifest(project.project_root)
         metric_ids = list(manifest.metrics.keys())
         expected_metric_ids = [
             "metric.test.number_of_people",
             "metric.test.collective_tenure",
             "metric.test.collective_window",
+            "metric.test.average_tenure",
+            "metric.test.average_tenure_minus_people",
         ]
         assert metric_ids == expected_metric_ids
 
+        assert (
+            len(manifest.metrics["metric.test.number_of_people"].type_params.input_measures) == 1
+        )
+        assert (
+            len(manifest.metrics["metric.test.collective_tenure"].type_params.input_measures) == 1
+        )
+        assert (
+            len(manifest.metrics["metric.test.collective_window"].type_params.input_measures) == 1
+        )
+        assert len(manifest.metrics["metric.test.average_tenure"].type_params.input_measures) == 2
+        assert (
+            len(
+                manifest.metrics[
+                    "metric.test.average_tenure_minus_people"
+                ].type_params.input_measures
+            )
+            == 3
+        )
+
 
 class TestInvalidRefMetrics:
     @pytest.fixture(scope="class")