Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/Features-20260306-152125.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Features
body: Add tags support for metrics via config.tags in YAML or +tags in dbt_project.yml
time: 2026-03-06T15:21:25.806855+09:00
custom:
Author: dtaniwaki
Issue: "12607"
11 changes: 9 additions & 2 deletions core/dbt/artifacts/resources/v1/metric.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal, Optional
from typing import Any, Dict, List, Literal, Optional, Union

from dbt.artifacts.resources.base import GraphResource
from dbt.artifacts.resources.types import NodeType
Expand All @@ -11,7 +11,9 @@
SourceFileMetadata,
WhereFilterIntersection,
)
from dbt.artifacts.resources.v1.config import list_str, metas
from dbt_common.contracts.config.base import BaseConfig, CompareBehavior, MergeBehavior
from dbt_common.contracts.config.metadata import ShowBehavior
from dbt_common.dataclass_schema import dbtClassMixin
from dbt_semantic_interfaces.references import MeasureReference, MetricReference
from dbt_semantic_interfaces.type_enums import (
Expand Down Expand Up @@ -150,9 +152,13 @@ class MetricConfig(BaseConfig):
default=None,
metadata=CompareBehavior.Exclude.meta(),
)

meta: Dict[str, Any] = field(default_factory=dict, metadata=MergeBehavior.Update.meta())

tags: Union[List[str], str] = field(
default_factory=list_str,
metadata=metas(ShowBehavior.Hide, MergeBehavior.Append, CompareBehavior.Exclude),
)


@dataclass
class Metric(GraphResource):
Expand All @@ -176,6 +182,7 @@ class Metric(GraphResource):

# These fields are only used in v1 metrics.
meta: Dict[str, Any] = field(default_factory=dict, metadata=MergeBehavior.Update.meta())

tags: List[str] = field(default_factory=list)

@property
Expand Down
9 changes: 9 additions & 0 deletions core/dbt/parser/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@
schema_file_keys = list(schema_file_keys_to_resource_types.keys())


def normalize_tags(tags: Union[List[str], str, None]) -> List[str]:
"""Return a sorted, deduplicated list of tags."""
if tags is None:
return []
if isinstance(tags, str):
tags = [tags]
return sorted(set(tags))


def trimmed(inp: str) -> str:
if len(inp) < 50:
return inp
Expand Down
40 changes: 22 additions & 18 deletions core/dbt/parser/schema_yaml_readers.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@
UnparsedSemanticModelConfig,
UnparsedSemanticResourceConfig,
)
from dbt.events.types import ValidationWarning
from dbt.exceptions import JSONValidationError, YamlParseDictError
from dbt.node_types import NodeType
from dbt.parser.common import YamlBlock
from dbt.parser.common import YamlBlock, normalize_tags
from dbt.parser.schemas import ParseResult, SchemaParser, YamlReader
from dbt_common.dataclass_schema import ValidationError
from dbt_common.exceptions import DbtInternalError
from dbt_common.events.functions import warn_or_error
from dbt_semantic_interfaces.type_enums import (
AggregationType,
ConversionCalculationType,
Expand Down Expand Up @@ -140,7 +142,7 @@ def parse_exposure(self, unparsed: UnparsedExposure) -> None:
f"Calculated a {type(config)} for an exposure, but expected an ExposureConfig"
)

tags = sorted(set(self.project.exposures.get("tags", []) + unparsed.tags + config.tags))
tags = normalize_tags(self.project.exposures.get("tags", []) + unparsed.tags + config.tags)
meta = {**self.project.exposures.get("meta", {}), **unparsed.meta, **config.meta}

config.tags = tags
Expand Down Expand Up @@ -535,6 +537,8 @@ def parse_metric(
rendered=True,
)

config.tags = normalize_tags(config.tags)

config = config.finalize_and_validate()

unrendered_config = self._generate_metric_config(
Expand All @@ -555,12 +559,21 @@ def parse_metric(
if "meta" in config and config["meta"]:
unparsed.meta = config["meta"]
meta = unparsed.meta
tags = unparsed.tags
if unparsed.tags:
# Top-level tags on v1 metrics are ignored; use config.tags instead.
warn_or_error(
ValidationWarning(
field_name=(
"top-level `tags:` (ignored; use `config.tags` in YAML "
"or `+tags` in dbt_project.yml instead)"
),
resource_type="metric (v1)",
node_name=unparsed.name,
)
)
elif isinstance(unparsed, UnparsedMetricV2):
# V2 Metrics do not have a top-level meta field; this should be part of
# the config.
# V2 Metrics do not have a top-level meta or tags field; use config only.
meta = {}
tags = []
else:
raise DbtInternalError(
f"Tried to parse a {type(unparsed)} into a metric, but expected "
Expand All @@ -582,7 +595,7 @@ def parse_metric(
time_granularity=unparsed.time_granularity,
filter=parse_where_filter(unparsed.filter),
meta=meta,
tags=tags,
tags=config.tags,
config=config,
unrendered_config=unrendered_config,
group=config.group,
Expand Down Expand Up @@ -1279,17 +1292,8 @@ def parse_saved_query(self, unparsed: UnparsedSavedQuery) -> None:
rendered=False,
)

# The parser handles plain strings just fine, but we need to be able
# to join two lists, remove duplicates, and sort, so we have to wrap things here.
def wrap_tags(s: Union[List[str], str]) -> List[str]:
if s is None:
return []
return [s] if isinstance(s, str) else s

config_tags = wrap_tags(config.get("tags"))
unparsed_tags = wrap_tags(unparsed.tags)
tags = list(set([*unparsed_tags, *config_tags]))
tags.sort()
raw_tags = [unparsed.tags] if isinstance(unparsed.tags, str) else list(unparsed.tags or [])
tags = normalize_tags(raw_tags + (config.get("tags") or []))

parsed = SavedQuery(
description=unparsed.description,
Expand Down
82 changes: 82 additions & 0 deletions tests/functional/metrics/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,88 @@
SELECT to_date('02/20/2023, 'mm/dd/yyyy') as date_day
"""

models_people_metrics_shared_tag_yml = """
version: 2

metrics:

- name: number_of_people
label: "Number of people"
description: Total count of people
type: simple
type_params:
measure: people
time_granularity: month
config:
tags:
- shared_tag

"""

models_people_metrics_top_level_tags_yml = """
version: 2

metrics:

- name: number_of_people
label: "Number of people"
description: Total count of people
type: simple
type_params:
measure: people
time_granularity: month
tags:
- top_level_tag

"""

models_people_metrics_tags_yml = """
version: 2

metrics:

- name: number_of_people
label: "Number of people"
description: Total count of people
type: simple
type_params:
measure: people
time_granularity: month
config:
tags:
- yaml_tag

- 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: 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_yml = """
version: 2

Expand Down
Loading
Loading