Repository: dbt-labs/dbt-core

Issue:
[CT-1618] [Feature] Metric timestamp and time_grains should be optional
### Is this your first time submitting a feature request?

- [X] I have read the [expectations for open source contributors](https://docs.getdbt.com/docs/contributing/oss-expectations)
- [X] I have searched the existing issues, and I could not find an existing issue for this feature
- [X] I am requesting a straightforward extension of existing dbt functionality, rather than a Big Idea better suited to a discussion

### Describe the feature

In today's world, we state that dbt metrics are inherently time bound - they must be associated with a timestamp and any query of them is aggregated at the grain provided. This was initially bound with #6379 but this is actually something we can pursue before beginning that work - they're not bound to one another.

**scratch** oh wait that's not entirely true 😅 

Based on feedback, we implemented a feature in the metrics package to support the 'all_time' grain where we aggregate to the base grain of the metric. So we already support a world where timestamp isn't really needed.

Additionally we have `derived` metrics which don't need timestamps because they combine the timestamps of other metrics.

So lets support this world we've created!

### The Proposal
- `timestamp` becomes an optional input
- `time_grains` becomes an optional input
- Rewrite dbt_metrics to make `grain` an optional input and the base behavior is all time. This will follow in another issue.

### Describe alternatives you've considered

Not doing this but we already "support" it!

### Who will this benefit?

Anyone who uses metrics but doesn't want to aggregate them by time!

### Are you interested in contributing this feature?

You betcha!

### Anything else?

_No response_


Discussion:

Resolving patch:
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -976,12 +976,12 @@ class Metric(GraphNode):
     description: str
     label: str
     calculation_method: str
-    timestamp: str
     expression: str
     filters: List[MetricFilter]
     time_grains: List[str]
     dimensions: List[str]
     resource_type: NodeType = field(metadata={"restrict": [NodeType.Metric]})
+    timestamp: Optional[str] = None
     window: Optional[MetricTime] = None
     model: Optional[str] = None
     model_unique_id: Optional[str] = None
diff --git a/core/dbt/contracts/graph/unparsed.py b/core/dbt/contracts/graph/unparsed.py
--- a/core/dbt/contracts/graph/unparsed.py
+++ b/core/dbt/contracts/graph/unparsed.py
@@ -484,9 +484,9 @@ class UnparsedMetric(dbtClassMixin, Replaceable):
     name: str
     label: str
     calculation_method: str
-    timestamp: str
     expression: str
     description: str = ""
+    timestamp: Optional[str] = None
     time_grains: List[str] = field(default_factory=list)
     dimensions: List[str] = field(default_factory=list)
     window: Optional[MetricTime] = None
@@ -518,6 +518,16 @@ def validate(cls, data):
                     f"The metric name '{data['name']}' is invalid.  It {', '.join(e for e in errors)}"
                 )
 
+        if data.get("timestamp") is None and data.get("time_grains") is not None:
+            raise ValidationError(
+                f"The metric '{data['name']} has time_grains defined but is missing a timestamp dimension."
+            )
+
+        if data.get("timestamp") is None and data.get("window") is not None:
+            raise ValidationError(
+                f"The metric '{data['name']} has a window defined but is missing a timestamp dimension."
+            )
+
         if data.get("model") is None and data.get("calculation_method") != "derived":
             raise ValidationError("Non-derived metrics require a 'model' property")
 


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
@@ -642,3 +642,58 @@
     meta:
         my_meta: 'testing'
 """
+
+metric_without_timestamp_or_timegrains_yml = """
+version: 2
+
+metrics:
+  - name: number_of_people
+    label: "Number of people"
+    description: Total count of people
+    model: "ref('people')"
+    calculation_method: count
+    expression: "*"
+    dimensions:
+      - favorite_color
+      - loves_dbt
+    meta:
+        my_meta: 'testing'
+"""
+
+invalid_metric_without_timestamp_with_time_grains_yml = """
+version: 2
+
+metrics:
+  - name: number_of_people
+    label: "Number of people"
+    description: Total count of people
+    model: "ref('people')"
+    time_grains: [day, week, month]
+    calculation_method: count
+    expression: "*"
+    dimensions:
+      - favorite_color
+      - loves_dbt
+    meta:
+        my_meta: 'testing'
+"""
+
+invalid_metric_without_timestamp_with_window_yml = """
+version: 2
+
+metrics:
+  - name: number_of_people
+    label: "Number of people"
+    description: Total count of people
+    model: "ref('people')"
+    window:
+      count: 14
+      period: day
+    calculation_method: count
+    expression: "*"
+    dimensions:
+      - favorite_color
+      - loves_dbt
+    meta:
+        my_meta: 'testing'
+"""
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
@@ -3,6 +3,7 @@
 from dbt.tests.util import run_dbt, get_manifest
 from dbt.exceptions import ParsingException
 
+
 from tests.functional.metrics.fixtures import (
     mock_purchase_data_csv,
     models_people_sql,
@@ -18,6 +19,9 @@
     invalid_derived_metric_contains_model_yml,
     derived_metric_yml,
     derived_metric_old_attr_names_yml,
+    metric_without_timestamp_or_timegrains_yml,
+    invalid_metric_without_timestamp_with_time_grains_yml,
+    invalid_metric_without_timestamp_with_window_yml
 )
 
 
@@ -46,6 +50,33 @@ def test_simple_metric(
         assert metric_ids == expected_metric_ids
 
 
+class TestSimpleMetricsNoTimestamp:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people_metrics.yml": metric_without_timestamp_or_timegrains_yml,
+            "people.sql": models_people_sql,
+        }
+
+    def test_simple_metric_no_timestamp(
+        self,
+        project,
+    ):
+        # initial run
+        results = run_dbt(["run"])
+        assert len(results) == 1
+        manifest = get_manifest(project.project_root)
+        metric_ids = list(manifest.metrics.keys())
+        expected_metric_ids = [
+            "metric.test.number_of_people",
+        ]
+        assert metric_ids == expected_metric_ids
+
+        # make sure the 'expression' metric depends on the two upstream metrics
+        metric_test = manifest.metrics["metric.test.number_of_people"]
+        assert metric_test.timestamp is None
+
+
 class TestInvalidRefMetrics:
     @pytest.fixture(scope="class")
     def models(self):
@@ -253,3 +284,41 @@ def models(self):
             "derived_metric.yml": derived_metric_old_attr_names_yml,
             "downstream_model.sql": downstream_model_sql,
         }
+
+
+class TestInvalidTimestampTimeGrainsMetrics:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people_metrics.yml": invalid_metric_without_timestamp_with_time_grains_yml,
+            "people.sql": models_people_sql,
+        }
+
+    # Tests that we get a ParsingException with an invalid metric definition.
+    # This metric definition is missing timestamp but HAS a time_grains property
+    def test_simple_metric(
+        self,
+        project,
+    ):
+        # initial run
+        with pytest.raises(ParsingException):
+            run_dbt(["run"])
+
+
+class TestInvalidTimestampWindowMetrics:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people_metrics.yml": invalid_metric_without_timestamp_with_window_yml,
+            "people.sql": models_people_sql,
+        }
+
+    # Tests that we get a ParsingException with an invalid metric definition.
+    # This metric definition is missing timestamp but HAS a window property
+    def test_simple_metric(
+        self,
+        project,
+    ):
+        # initial run
+        with pytest.raises(ParsingException):
+            run_dbt(["run"])