Repository: dbt-labs/dbt-core

Issue:
[CT-1091] [Feature] Add Lookback Attribute for Metrics
### 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

The metrics package is looking to expand the types of metrics that it can operate on and one consistent piece of feedback we've received is that people want to be able to define metrics that have a concept of time included in their definition. IE 14 Day Average, etc.

This functionality has an [open PR here](https://github.com/dbt-labs/dbt_metrics/pull/92) for the metrics package. We've proven that it works but want to move the definition of this metric aspect into the metric itself instead of meta.

Example:

```yml
metrics:
  - name: base_lookback_metric
    model: ref('fact_orders')
    label: Total Discount ($)
    timestamp: order_date
    time_grains: [day, week, month]
    type: sum
    sql: discount_total
    dimensions:
      - had_discount
      - order_country
      
    window: 14 days
   
```

### Describe alternatives you've considered

Not including it.

### Who will this benefit?

Anyone who wants to use metrics that are cumulative or lookback.

### Are you interested in contributing this feature?

Yep!

### Anything else?

I am not married or even really attached to the name of lookback. It is a placeholder while we work on the functionality.


Discussion:

Resolving patch:
diff --git a/core/dbt/contracts/graph/parsed.py b/core/dbt/contracts/graph/parsed.py
--- a/core/dbt/contracts/graph/parsed.py
+++ b/core/dbt/contracts/graph/parsed.py
@@ -812,6 +812,7 @@ class ParsedMetric(UnparsedBaseNode, HasUniqueID, HasFqn):
     filters: List[MetricFilter]
     time_grains: List[str]
     dimensions: List[str]
+    window: Optional[str]
     model: Optional[str] = None
     model_unique_id: Optional[str] = None
     resource_type: NodeType = NodeType.Metric
@@ -834,6 +835,9 @@ def search_name(self):
     def same_model(self, old: "ParsedMetric") -> bool:
         return self.model == old.model
 
+    def same_window(self, old: "ParsedMetric") -> bool:
+        return self.window == old.window
+
     def same_dimensions(self, old: "ParsedMetric") -> bool:
         return self.dimensions == old.dimensions
 
@@ -866,6 +870,7 @@ def same_contents(self, old: Optional["ParsedMetric"]) -> bool:
 
         return (
             self.same_model(old)
+            and self.same_window(old)
             and self.same_dimensions(old)
             and self.same_filters(old)
             and self.same_description(old)
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
@@ -452,12 +452,13 @@ class UnparsedMetric(dbtClassMixin, Replaceable):
     name: str
     label: str
     type: str
+    timestamp: str
     model: Optional[str] = None
     description: str = ""
     sql: Union[str, int] = ""
-    timestamp: Optional[str] = None
     time_grains: List[str] = field(default_factory=list)
     dimensions: List[str] = field(default_factory=list)
+    window: Optional[str] = None
     filters: List[MetricFilter] = field(default_factory=list)
     meta: Dict[str, Any] = field(default_factory=dict)
     tags: List[str] = field(default_factory=list)
diff --git a/core/dbt/parser/schemas.py b/core/dbt/parser/schemas.py
--- a/core/dbt/parser/schemas.py
+++ b/core/dbt/parser/schemas.py
@@ -1050,6 +1050,7 @@ def parse_metric(self, unparsed: UnparsedMetric) -> ParsedMetric:
             sql=str(unparsed.sql),
             timestamp=unparsed.timestamp,
             dimensions=unparsed.dimensions,
+            window=unparsed.window,
             time_grains=unparsed.time_grains,
             filters=unparsed.filters,
             meta=unparsed.meta,


Test patch:
diff --git a/test/unit/test_contracts_graph_unparsed.py b/test/unit/test_contracts_graph_unparsed.py
--- a/test/unit/test_contracts_graph_unparsed.py
+++ b/test/unit/test_contracts_graph_unparsed.py
@@ -697,6 +697,7 @@ def get_ok_dict(self):
                     "operator": "=",
                 }
             ],
+            'window': '14 days',
             'tags': [],
             'meta': {
                 'is_okr': True
@@ -710,9 +711,11 @@ def get_ok_expression_dict(self):
             'description': '',
             'type': 'expression',
             'sql': "{{ metric('revenue') }} / {{ metric('customers') }}",
+            'timestamp': "signup_date",
             'time_grains': ['day', 'week', 'month'],
             'dimensions': [],
             'filters': [],
+            'window': '',
             'tags': [],
             'meta': {
                 'is_okr': True
@@ -735,6 +738,7 @@ def test_ok(self):
                 value='True',
                 operator="=",
             )],
+            window="14 days",
             meta={'is_okr': True},
         )
         dct = self.get_ok_dict()
@@ -750,9 +754,10 @@ def test_ok_metric_no_model(self):
             description="",
             type='expression',
             sql="{{ metric('revenue') }} / {{ metric('customers') }}",
-            timestamp=None,
+            timestamp="signup_date",
             time_grains=['day', 'week', 'month'],
             dimensions=[],
+            window='',
             meta={'is_okr': True},
         )
         dct = self.get_ok_expression_dict()
diff --git a/test/unit/test_graph_selector_methods.py b/test/unit/test_graph_selector_methods.py
--- a/test/unit/test_graph_selector_methods.py
+++ b/test/unit/test_graph_selector_methods.py
@@ -380,6 +380,7 @@ def make_metric(pkg, name, path=None):
            value=True,
            operator="=",
         )],
+        window='',
         meta={'is_okr': True},
         tags=['okrs'],
     )
diff --git a/test/unit/test_manifest.py b/test/unit/test_manifest.py
--- a/test/unit/test_manifest.py
+++ b/test/unit/test_manifest.py
@@ -115,6 +115,7 @@ def setUp(self):
                 )],
                 meta={'is_okr': True},
                 tags=['okrs'],
+                window='',
                 resource_type=NodeType.Metric,
                 depends_on=DependsOn(nodes=['model.root.multi']),
                 refs=[['multi']],
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
@@ -38,6 +38,20 @@
         operator: 'is'
         value: 'true'
 
+  - name: collective_window
+    label: "Collective window"
+    description: Testing window
+    model: "ref('people')"
+    type: sum
+    sql: tenure
+    timestamp: created_at
+    time_grains: [day]
+    window: 14 days
+    filters:
+      - field: loves_dbt
+        operator: 'is'
+        value: 'true'
+
 """
 
 models__people_sql = """
@@ -66,7 +80,11 @@ def test_simple_metric(
         assert len(results) == 1
         manifest = get_manifest(project.project_root)
         metric_ids = list(manifest.metrics.keys())
-        expected_metric_ids = ["metric.test.number_of_people", "metric.test.collective_tenure"]
+        expected_metric_ids = [
+            "metric.test.number_of_people",
+            "metric.test.collective_tenure",
+            "metric.test.collective_window",
+        ]
         assert metric_ids == expected_metric_ids
 
 
@@ -260,6 +278,7 @@ def test_names_with_spaces(self, project):
         time_grains: {{ m.time_grains }}
         dimensions: {{ m.dimensions }}
         filters: {{ m.filters }}
+        window: {{ m.window }}
     {% endfor %}
 
 {% endif %}
@@ -430,6 +449,7 @@ def test_expression_metric(
                 "time_grains",
                 "dimensions",
                 "filters",
+                "window",
             ]:
                 expected_value = getattr(parsed_metric_node, property)
                 assert f"{property}: {expected_value}" in compiled_code