Repository: dbt-labs/dbt-core

Issue:
Nest Metric node `meta` under `config`
### Housekeeping

- [X] I am a maintainer of dbt-core

### Short description

The old/deprecated way of having meta was putting it directly on a node. However, awhile back we deprecated that paradigm and moved to nesting meta under a node's `config`. When revamped `Metric` nodes in 1.6, we forgot to move to the new `meta` paradigm, and thus Metric nodes have only been supporting the deprecated path. We need to ensure we support the desired path.

### Acceptance criteria

- Metric nodes have a `meta` property under their `config` property
- During parsing, flat `meta` specifications should end up under the `config.meta`

### Suggested Tests

- Metric meta can be specified in the `dbt_project.yaml` config specification
- In metric yaml, people can specify `meta` directly on a metric or under a metric's config, both should end up in a metrics `config.meta`

### Impact to Other Teams

Change impacts Semantic Layer team (as they requested this).
Change impacts Artifacts team (change to the artifacts)

### Will backports be required?

No

### Context

_No response_


Discussion:

Resolving patch:
diff --git a/core/dbt/artifacts/resources/v1/metric.py b/core/dbt/artifacts/resources/v1/metric.py
--- a/core/dbt/artifacts/resources/v1/metric.py
+++ b/core/dbt/artifacts/resources/v1/metric.py
@@ -8,7 +8,7 @@
     SourceFileMetadata,
     WhereFilterIntersection,
 )
-from dbt_common.contracts.config.base import BaseConfig, CompareBehavior
+from dbt_common.contracts.config.base import BaseConfig, CompareBehavior, MergeBehavior
 from dbt_common.dataclass_schema import dbtClassMixin
 from dbt_semantic_interfaces.references import MeasureReference, MetricReference
 from dbt_semantic_interfaces.type_enums import (
@@ -101,6 +101,8 @@ class MetricConfig(BaseConfig):
         metadata=CompareBehavior.Exclude.meta(),
     )
 
+    meta: Dict[str, Any] = field(default_factory=dict, metadata=MergeBehavior.Update.meta())
+
 
 @dataclass
 class Metric(GraphResource):
@@ -112,7 +114,7 @@ class Metric(GraphResource):
     filter: Optional[WhereFilterIntersection] = None
     metadata: Optional[SourceFileMetadata] = None
     resource_type: Literal[NodeType.Metric]
-    meta: Dict[str, Any] = field(default_factory=dict)
+    meta: Dict[str, Any] = field(default_factory=dict, metadata=MergeBehavior.Update.meta())
     tags: List[str] = field(default_factory=list)
     config: MetricConfig = field(default_factory=MetricConfig)
     unrendered_config: Dict[str, Any] = field(default_factory=dict)
diff --git a/core/dbt/parser/schema_yaml_readers.py b/core/dbt/parser/schema_yaml_readers.py
--- a/core/dbt/parser/schema_yaml_readers.py
+++ b/core/dbt/parser/schema_yaml_readers.py
@@ -366,6 +366,11 @@ def parse_metric(self, unparsed: UnparsedMetric, generated: bool = False):
                 f"Calculated a {type(config)} for a metric, but expected a MetricConfig"
             )
 
+        # If we have meta in the config, copy to node level, for backwards
+        # compatibility with earlier node-only config.
+        if "meta" in config and config["meta"]:
+            unparsed.meta = config["meta"]
+
         parsed = Metric(
             resource_type=NodeType.Metric,
             package_name=package_name,


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
@@ -97,6 +97,62 @@
 models_people_metrics_yml = """
 version: 2
 
+metrics:
+
+  - name: number_of_people
+    label: "Number of people"
+    description: Total count of people
+    type: simple
+    type_params:
+      measure: people
+    config:
+      meta:
+        my_meta_config: 'config'
+
+  - name: collective_tenure
+    label: "Collective tenure"
+    description: Total number of years of team experience
+    type: simple
+    type_params:
+      measure:
+        name: years_tenure
+        filter: "{{ Dimension('id__loves_dbt') }} is true"
+        join_to_timespine: true
+        fill_nulls_with: 0
+
+  - name: collective_window
+    label: "Collective window"
+    description: Testing window
+    type: simple
+    type_params:
+      measure:
+        name: years_tenure
+        filter: "{{ Dimension('id__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
+
+"""
+
+models_people_metrics_meta_top_yml = """
+version: 2
+
 metrics:
 
   - name: number_of_people
@@ -106,7 +162,7 @@
     type_params:
       measure: people
     meta:
-        my_meta: 'testing'
+        my_meta_top: 'top'
 
   - name: collective_tenure
     label: "Collective tenure"
@@ -458,6 +514,34 @@
 
 """
 
+meta_metric_level_schema_yml = """
+version: 2
+
+metrics:
+
+  - name: number_of_people
+    label: "Number of people"
+    description: Total count of people
+    type: simple
+    type_params:
+      measure: people
+    config:
+      meta:
+         my_meta_config: 'config
+    meta:
+        my_meta_direct: 'direct'
+
+  - name: collective_tenure
+    label: "Collective tenure"
+    description: Total number of years of team experience
+    type: simple
+    type_params:
+      measure:
+        name: years_tenure
+        filter: "{{ Dimension('id__loves_dbt') }} is true"
+
+"""
+
 enabled_metric_level_schema_yml = """
 version: 2
 
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
@@ -8,6 +8,7 @@
 from tests.functional.metrics.fixtures import (
     models_people_sql,
     models_people_metrics_yml,
+    models_people_metrics_meta_top_yml,
     metricflow_time_spine_sql,
     disabled_metric_level_schema_yml,
     enabled_metric_level_schema_yml,
@@ -40,22 +41,24 @@ def models(self):
     def project_config_update(self):
         return {
             "metrics": {
-                "average_tenure_minus_people": {
-                    "enabled": True,
-                },
+                "test": {
+                    "average_tenure_minus_people": {
+                        "enabled": False,
+                    },
+                }
             }
         }
 
     def test_enabled_metric_config_dbt_project(self, project):
         run_dbt(["parse"])
         manifest = get_manifest(project.project_root)
-        assert "metric.test.average_tenure_minus_people" in manifest.metrics
+        assert "metric.test.average_tenure_minus_people" not in manifest.metrics
 
         new_enabled_config = {
             "metrics": {
                 "test": {
                     "average_tenure_minus_people": {
-                        "enabled": False,
+                        "enabled": True,
                     },
                 }
             }
@@ -63,7 +66,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.average_tenure_minus_people" not in manifest.metrics
+        assert "metric.test.average_tenure_minus_people" in manifest.metrics
         assert "metric.test.collective_tenure" in manifest.metrics
 
 
@@ -204,3 +207,84 @@ def test_disabling_upstream_metric_errors(self, project):
                 "The metric `number_of_people` is disabled and thus cannot be referenced."
             )
             assert expected_msg in str(excinfo.value)
+
+
+# Test meta config in dbt_project.yml
+class TestMetricMetaConfigProjectLevel(MetricConfigTests):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people.sql": models_people_sql,
+            "metricflow_time_spine.sql": metricflow_time_spine_sql,
+            "semantic_model_people.yml": semantic_model_people_yml,
+            "schema.yml": models_people_metrics_yml,
+        }
+
+    @pytest.fixture(scope="class")
+    def project_config_update(self):
+        return {
+            "metrics": {
+                "test": {
+                    "average_tenure_minus_people": {
+                        "+meta": {"project_field": "project_value"},
+                    },
+                }
+            }
+        }
+
+    def test_meta_metric_config_dbt_project(self, project):
+        run_dbt(["parse"])
+        manifest = get_manifest(project.project_root)
+        assert "metric.test.average_tenure_minus_people" in manifest.metrics
+        # for backwards compatibility the config level meta gets copied to the top level meta
+        assert manifest.metrics.get("metric.test.average_tenure_minus_people").config.meta == {
+            "project_field": "project_value"
+        }
+        assert manifest.metrics.get("metric.test.average_tenure_minus_people").meta == {
+            "project_field": "project_value"
+        }
+
+
+# Test setting config at config level
+class TestMetricMetaConfigLevel(MetricConfigTests):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people.sql": models_people_sql,
+            "metricflow_time_spine.sql": metricflow_time_spine_sql,
+            "semantic_model_people.yml": semantic_model_people_yml,
+            "schema.yml": models_people_metrics_yml,
+        }
+
+    def test_meta_metric_config_yaml(self, project):
+        run_dbt(["parse"])
+        manifest = get_manifest(project.project_root)
+        assert "metric.test.number_of_people" in manifest.metrics
+        assert manifest.metrics.get("metric.test.number_of_people").config.meta == {
+            "my_meta_config": "config"
+        }
+        assert manifest.metrics.get("metric.test.number_of_people").meta == {
+            "my_meta_config": "config"
+        }
+
+
+# Test setting config at metric level- expect to exist in config after parsing
+class TestMetricMetaTopLevel(MetricConfigTests):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people.sql": models_people_sql,
+            "metricflow_time_spine.sql": metricflow_time_spine_sql,
+            "semantic_model_people.yml": semantic_model_people_yml,
+            "schema.yml": models_people_metrics_meta_top_yml,
+        }
+
+    def test_meta_metric_config_yaml(self, project):
+        run_dbt(["parse"])
+        manifest = get_manifest(project.project_root)
+        assert "metric.test.number_of_people" in manifest.metrics
+        # for backwards compatibility the config level meta gets copied to the top level meta
+        assert manifest.metrics.get("metric.test.number_of_people").config.meta != {
+            "my_meta_top": "top"
+        }
+        assert manifest.metrics.get("metric.test.number_of_people").meta == {"my_meta_top": "top"}