Skip to content

Commit 14d57ae

Browse files
m1n0claude
andauthored
fix(freshness): send freshness diagnostics in milliseconds with measure=time (#2770)
V4 (contract-scan) freshness checks rendered in Soda Cloud check charts as raw, unitless floats (e.g. "11.15...") instead of a human-readable duration. The diagnostics payload was wrong in two ways: 1. measure missing: diagnostics never set a `measure` key, so Soda Cloud and the frontend formatted the value as a plain number rather than a duration. 2. wrong unit: `value` (and the `fail` threshold bounds) were emitted in the check's configured unit (fractional hours/days) instead of milliseconds. The frontend formats a "time" measure by dividing the wire value by 1000 and rendering it as "X days and Y hours", so it relies on the value being in milliseconds and tagged with `measure="time"` — exactly the V3 (SodaCL) wire contract, where freshness sends round(total_seconds * 1000) ms + measure="time". This adds two opt-in hooks on the base CheckResult — `get_soda_cloud_measure()` (default None) and `to_soda_cloud_measure_value()` (identity) — overridden by the freshness result to return "time" and scale the configured-unit value to milliseconds. The diagnostics builder routes the value and fail bounds through the converter and only emits a `measure` key when non-None, so payloads for all non-time check types are byte-for-byte unchanged. The unit scale lookup falls back to the default ("hour") for a missing/unknown unit so it can never KeyError mid-upload and abort the whole scan's result upload. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d605511 commit 14d57ae

5 files changed

Lines changed: 135 additions & 7 deletions

File tree

soda-core/src/soda_core/common/soda_cloud.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,14 +1774,25 @@ def _build_diagnostics_json_dict(check_result: CheckResult) -> Optional[dict]:
17741774
# if check_result.outcome == CheckOutcome.EXCLUDED:
17751775
# return None
17761776

1777-
return {
1777+
raw_value = (
17781778
# TODO: this default 0 value is here only because check.diagnostics.value is a required non-nullable field in the api.
1779-
"value": int(check_result.threshold_value)
1779+
int(check_result.threshold_value)
17801780
if isinstance(check_result.threshold_value, bool)
1781-
else (check_result.threshold_value or 0),
1781+
else (check_result.threshold_value or 0)
1782+
)
1783+
diagnostics: dict = {
1784+
# ``value`` (and the fail thresholds below) are converted into the measure's
1785+
# wire unit — identity for plain numbers, milliseconds for "time".
1786+
"value": check_result.to_soda_cloud_measure_value(raw_value),
17821787
"fail": _build_fail_threshold(check_result),
17831788
"v4": _build_v4_diagnostics_check_type_json_dict(check_result),
17841789
}
1790+
# Unit/type marker so Soda Cloud can format the value (e.g. freshness as a
1791+
# duration rather than a raw float). Omitted for plain-number checks.
1792+
measure: Optional[str] = check_result.get_soda_cloud_measure()
1793+
if measure is not None:
1794+
diagnostics["measure"] = measure
1795+
return diagnostics
17851796

17861797

17871798
def _build_v4_diagnostics_check_type_json_dict(check_result: CheckResult) -> Optional[dict]:
@@ -1900,11 +1911,15 @@ def _build_schema_column(column_metadata: ColumnMetadata) -> Optional[dict]:
19001911
def _build_fail_threshold(check_result: CheckResult) -> Optional[dict]:
19011912
threshold: Threshold = check_result.check.threshold
19021913
if threshold:
1914+
# Bounds are expressed in the check's native unit; convert into the measure's
1915+
# wire unit so the chart's threshold line matches the value (ms for "time").
1916+
# Identity for plain-number checks, so existing payloads are unchanged.
1917+
convert = check_result.to_soda_cloud_measure_value
19031918
return {
1904-
"greaterThan": threshold.must_be_less_than_or_equal,
1905-
"greaterThanOrEqual": threshold.must_be_less_than,
1906-
"lessThan": threshold.must_be_greater_than_or_equal,
1907-
"lessThanOrEqual": threshold.must_be_greater_than,
1919+
"greaterThan": convert(threshold.must_be_less_than_or_equal),
1920+
"greaterThanOrEqual": convert(threshold.must_be_less_than),
1921+
"lessThan": convert(threshold.must_be_greater_than_or_equal),
1922+
"lessThanOrEqual": convert(threshold.must_be_greater_than),
19081923
}
19091924
return None
19101925

soda-core/src/soda_core/contracts/contract_verification.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,24 @@ def is_not_evaluated(self) -> bool:
403403
def is_excluded(self) -> bool:
404404
return self.outcome == CheckOutcome.EXCLUDED
405405

406+
def get_soda_cloud_measure(self) -> Optional[str]:
407+
"""Unit/type marker Soda Cloud uses to format this check's value.
408+
409+
``None`` (the default) means "render as a plain number". Subtypes whose
410+
value represents a duration return ``"time"`` so Soda Cloud formats it as a
411+
human-readable duration instead of a raw float.
412+
"""
413+
return None
414+
415+
def to_soda_cloud_measure_value(self, value: Optional[Number]) -> Optional[Number]:
416+
"""Convert a value (the check value or a threshold bound) into the unit Soda
417+
Cloud expects for ``get_soda_cloud_measure()``.
418+
419+
Identity by default. Overridden where the wire unit differs from the check's
420+
native unit — e.g. the ``"time"`` measure is always milliseconds.
421+
"""
422+
return value
423+
406424
def log_table_row(self) -> dict:
407425
row = {}
408426
row["Column"] = self.check.column_name if self.check.column_name else "[dataset-level]"

soda-core/src/soda_core/contracts/impl/check_types/freshness_check.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,29 @@ def __init__(
305305
self.freshness_in_seconds: Optional[int] = freshness_in_seconds
306306
self.unit: Optional[str] = unit
307307

308+
# Seconds per freshness unit, mirroring _convert_freshness_seconds_to_check_unit.
309+
_UNIT_SECONDS: dict[str, int] = {"second": 1, "minute": 60, "hour": 60 * 60, "day": 60 * 60 * 24}
310+
# Matches FreshnessCheckImplBase.__init__, which defaults the unit to "hour".
311+
_DEFAULT_UNIT: str = "hour"
312+
313+
def get_soda_cloud_measure(self) -> Optional[str]:
314+
# Freshness is semantically a duration: tag it so Soda Cloud formats the
315+
# value as "X days and Y hours" instead of a raw float.
316+
return "time"
317+
318+
def to_soda_cloud_measure_value(self, value: Optional[float | int]) -> Optional[int]:
319+
# Soda Cloud's "time" measure expects milliseconds — this is the V3 wire
320+
# contract (soda-library FreshnessCheck sent round(total_seconds * 1000)).
321+
# The check value and its thresholds are expressed in the configured ``unit``,
322+
# so scale unit -> milliseconds.
323+
if value is None:
324+
return None
325+
# ``unit`` is set from the check (default "hour"), but it is typed Optional and
326+
# an unknown value would KeyError mid-upload — fall back to the configured
327+
# default so a bad/missing unit never aborts the whole scan's result upload.
328+
unit_seconds: int = self._UNIT_SECONDS.get(self.unit, self._UNIT_SECONDS[self._DEFAULT_UNIT])
329+
return round(value * unit_seconds * 1000)
330+
308331
# def log_summary(self, logs: Logs) -> None:
309332
# super().log_summary(logs)
310333
#

soda-tests/tests/integration/test_freshness_check.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ def test_freshness(data_source_test_helper: DataSourceTestHelper):
6969
"expectedTimestampUtc": "2025-01-04T10:00:00+00:00",
7070
"datasetRowsTested": 6,
7171
}
72+
# Freshness diagnostics must carry the "time" measure marker, and (matching the
73+
# V3 soda-library wire contract) the value + fail threshold must be in
74+
# milliseconds so Soda Cloud renders a duration instead of a raw float.
75+
assert check_json["diagnostics"]["measure"] == "time"
76+
assert check_json["diagnostics"]["value"] == 3_600_000 # 1 hour in ms
77+
# threshold "must_be_less_than: 2" (hours) -> fail when >= 2h -> 7_200_000 ms
78+
assert check_json["diagnostics"]["fail"]["greaterThanOrEqual"] == 7_200_000
7279

7380

7481
def test_freshness_in_days(data_source_test_helper: DataSourceTestHelper):

soda-tests/tests/unit/test_soda_cloud.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,71 @@ def test_build_diagnostics_json_dict_casts_bool_to_int(threshold_value, expected
769769
assert not isinstance(diagnostics["value"], bool)
770770

771771

772+
def _build_freshness_check_result(unit: str, threshold_value: float) -> "FreshnessCheckResult":
773+
from soda_core.contracts.impl.check_types.freshness_check import (
774+
FreshnessCheckResult,
775+
)
776+
777+
return FreshnessCheckResult(
778+
check=mock.MagicMock(),
779+
outcome=CheckOutcome.PASSED,
780+
threshold_value=threshold_value,
781+
diagnostic_metric_values={
782+
"dataset_rows_tested": 6,
783+
"check_rows_tested": 6,
784+
f"freshness_in_{unit}s": threshold_value,
785+
},
786+
max_timestamp=datetime(2025, 1, 4, 9, 0, 0, tzinfo=timezone.utc),
787+
max_timestamp_utc=datetime(2025, 1, 4, 9, 0, 0, tzinfo=timezone.utc),
788+
data_timestamp=datetime(2025, 1, 4, 10, 0, 0, tzinfo=timezone.utc),
789+
data_timestamp_utc=datetime(2025, 1, 4, 10, 0, 0, tzinfo=timezone.utc),
790+
freshness="1:00:00",
791+
freshness_in_seconds=3600,
792+
unit=unit,
793+
)
794+
795+
796+
def test_build_diagnostics_json_dict_sets_measure_time_for_freshness():
797+
# V4 (contract-scan) freshness values rendered as raw floats in the check charts
798+
# because soda-core never emitted a unit/type marker. Soda Cloud reads
799+
# ``diagnostics.measure`` (BE ``GenericCoreCheckDiagnostics.getMeasure()``);
800+
# for freshness it must be ``"time"`` so the value is formatted as a duration.
801+
check_result = _build_freshness_check_result(unit="hour", threshold_value=1.0)
802+
803+
diagnostics = _build_diagnostics_json_dict(check_result)
804+
805+
assert diagnostics["measure"] == "time"
806+
# V3 wire contract (soda-library FreshnessCheck): the "time" measure value is in
807+
# milliseconds, scaled from the check's configured `unit`. 1.0 hour -> 3_600_000 ms.
808+
assert diagnostics["value"] == 3_600_000
809+
810+
811+
def test_build_diagnostics_json_dict_scales_freshness_days_to_milliseconds():
812+
# Locks the unit -> milliseconds scale table for the largest unit. 1.0 day ->
813+
# 86_400_000 ms. This is the float the ticket showed rendered raw ("11.15...").
814+
check_result = _build_freshness_check_result(unit="day", threshold_value=1.0)
815+
816+
diagnostics = _build_diagnostics_json_dict(check_result)
817+
818+
assert diagnostics["measure"] == "time"
819+
assert diagnostics["value"] == 86_400_000
820+
821+
822+
def test_build_diagnostics_json_dict_omits_measure_for_non_time_checks():
823+
# Non-duration checks (e.g. row_count) carry no measure marker, so the key is
824+
# absent and Soda Cloud keeps formatting the value as a plain number. This keeps
825+
# the existing payload byte-for-byte unchanged for all non-time check types.
826+
check_result = CheckResult(
827+
check=mock.MagicMock(),
828+
outcome=CheckOutcome.PASSED,
829+
threshold_value=42,
830+
)
831+
832+
diagnostics = _build_diagnostics_json_dict(check_result)
833+
834+
assert "measure" not in diagnostics
835+
836+
772837
def test_build_token_usage_dicts_serialization():
773838
from soda_core.common.soda_cloud import _build_token_usage_dicts
774839

0 commit comments

Comments
 (0)