Skip to content

Commit fd9ef87

Browse files
[webserver] accept user-provided tags on report_asset_materialization + report_asset_observation
The Python SDK supports arbitrary tags on runless asset events (AssetMaterialization/AssetObservation tags=...), but the REST endpoints silently drop any field outside their allowlists — so external (non-Python) writers reporting events over REST cannot attach data-version provenance tags like dagster/input_data_version/<upstream>. This adds an optional 'tags' param to both endpoints (json body, or json-encoded query param mirroring 'metadata' handling). Validation is unchanged: tags flow into the existing event construction, where validate_asset_event_tags already exempts system asset event tags and strict-validates the rest, surfacing errors via the existing 400 path. The dedicated data_version param takes precedence over a conflicting dagster/data_version tag. Also fixes a copy-paste typo in the observation handler's construction-error message (said AssetMaterialization). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b8c776f commit fd9ef87

2 files changed

Lines changed: 162 additions & 2 deletions

File tree

python_modules/dagster-webserver/dagster_webserver/external_assets.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,32 @@ async def handle_report_asset_materialization_request(
9090
return unauthorized
9191

9292
tags = context.get_reporting_user_tags()
93+
94+
user_tags = None
95+
if ReportAssetMatParam.tags in json_body:
96+
user_tags = json_body[ReportAssetMatParam.tags]
97+
elif ReportAssetMatParam.tags in request.query_params:
98+
try:
99+
user_tags = json.loads(request.query_params[ReportAssetMatParam.tags])
100+
except Exception as exc:
101+
return JSONResponse(
102+
{
103+
"error": f"Error parsing tags json: {exc}",
104+
},
105+
status_code=400,
106+
)
107+
if user_tags is not None:
108+
if not isinstance(user_tags, dict):
109+
return JSONResponse(
110+
{
111+
"error": "Expected tags to be a json object.",
112+
},
113+
status_code=400,
114+
)
115+
# merged before the dedicated data_version param so that the explicit
116+
# param takes precedence over a conflicting tag
117+
tags.update(user_tags)
118+
93119
data_version = _value_from_body_or_params(ReportAssetMatParam.data_version, request, json_body)
94120
if data_version is not None:
95121
tags[DATA_VERSION_TAG] = data_version
@@ -297,6 +323,32 @@ async def handle_report_asset_observation_request(
297323
description = _value_from_body_or_params(ReportAssetObsParam.description, request, json_body)
298324

299325
tags = context.get_reporting_user_tags()
326+
327+
user_tags = None
328+
if ReportAssetObsParam.tags in json_body:
329+
user_tags = json_body[ReportAssetObsParam.tags]
330+
elif ReportAssetObsParam.tags in request.query_params:
331+
try:
332+
user_tags = json.loads(request.query_params[ReportAssetObsParam.tags])
333+
except Exception as exc:
334+
return JSONResponse(
335+
{
336+
"error": f"Error parsing tags json: {exc}",
337+
},
338+
status_code=400,
339+
)
340+
if user_tags is not None:
341+
if not isinstance(user_tags, dict):
342+
return JSONResponse(
343+
{
344+
"error": "Expected tags to be a json object.",
345+
},
346+
status_code=400,
347+
)
348+
# merged before the dedicated data_version param so that the explicit
349+
# param takes precedence over a conflicting tag
350+
tags.update(user_tags)
351+
300352
data_version = _value_from_body_or_params(ReportAssetObsParam.data_version, request, json_body)
301353
if data_version is not None:
302354
tags[DATA_VERSION_TAG] = data_version
@@ -313,7 +365,7 @@ async def handle_report_asset_observation_request(
313365
except Exception as exc:
314366
return JSONResponse(
315367
{
316-
"error": f"Error constructing AssetMaterialization: {exc}",
368+
"error": f"Error constructing AssetObservation: {exc}",
317369
},
318370
status_code=400,
319371
)
@@ -334,6 +386,7 @@ class ReportAssetMatParam:
334386
metadata = "metadata"
335387
description = "description"
336388
partition = "partition"
389+
tags = "tags"
337390

338391

339392
class ReportAssetCheckEvalParam:
@@ -359,3 +412,4 @@ class ReportAssetObsParam:
359412
metadata = "metadata"
360413
description = "description"
361414
partition = "partition"
415+
tags = "tags"

python_modules/dagster-webserver/dagster_webserver_tests/webserver/test_asset_events.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,70 @@ def test_report_asset_materialization_endpoint(instance: DagsterInstance, test_c
130130
in response.json()["error"]
131131
)
132132

133+
# user-provided tags (json body) — e.g. data-version provenance tags set by
134+
# external writers reporting materializations over REST
135+
response = test_client.post(
136+
f"/report_asset_materialization/{my_asset_key}",
137+
json={
138+
"data_version": "v2",
139+
"tags": {
140+
"dagster/input_data_version/upstream/key": "12345",
141+
"my_tag": "my_value",
142+
},
143+
},
144+
)
145+
assert response.status_code == 200, response.json()
146+
evt = instance.get_latest_materialization_event(AssetKey(my_asset_key))
147+
assert evt and evt.asset_materialization
148+
tags = evt.asset_materialization.tags
149+
assert tags
150+
assert tags["dagster/input_data_version/upstream/key"] == "12345"
151+
assert tags["my_tag"] == "my_value"
152+
assert tags[DATA_VERSION_TAG] == "v2"
153+
154+
# tags via query params (json encoded)
155+
response = test_client.post(
156+
f"/report_asset_materialization/{my_asset_key}",
157+
params={"tags": json.dumps({"my_tag": "param_value"})},
158+
)
159+
assert response.status_code == 200, response.json()
160+
evt = instance.get_latest_materialization_event(AssetKey(my_asset_key))
161+
assert evt and evt.asset_materialization
162+
tags = evt.asset_materialization.tags
163+
assert tags
164+
assert tags["my_tag"] == "param_value"
165+
166+
# the dedicated data_version param takes precedence over a conflicting tag
167+
response = test_client.post(
168+
f"/report_asset_materialization/{my_asset_key}",
169+
json={
170+
"data_version": "param_wins",
171+
"tags": {DATA_VERSION_TAG: "tag_loses"},
172+
},
173+
)
174+
assert response.status_code == 200, response.json()
175+
evt = instance.get_latest_materialization_event(AssetKey(my_asset_key))
176+
assert evt and evt.asset_materialization
177+
tags = evt.asset_materialization.tags
178+
assert tags
179+
assert tags[DATA_VERSION_TAG] == "param_wins"
180+
181+
# bad tags: query param not json encoded
182+
response = test_client.post(
183+
f"/report_asset_materialization/{my_asset_key}",
184+
params={"tags": "not json {"},
185+
)
186+
assert response.status_code == 400
187+
assert "Error parsing tags json" in response.json()["error"]
188+
189+
# bad tags: not an object
190+
response = test_client.post(
191+
f"/report_asset_materialization/{my_asset_key}",
192+
json={"tags": "im_just_a_string"},
193+
)
194+
assert response.status_code == 400
195+
assert "Expected tags to be a json object" in response.json()["error"]
196+
133197

134198
def test_report_asset_materialization_apis_consistent(
135199
instance: DagsterInstance, test_client: TestClient
@@ -141,6 +205,7 @@ def test_report_asset_materialization_apis_consistent(
141205
"data_version": "so_new",
142206
"partition": "2023-09-23",
143207
"description": "boo",
208+
"tags": {"dagster/input_data_version/up/stream": "42", "my_tag": "my_value"},
144209
}
145210

146211
# sample has entry for all supported params (banking on usage of enum)
@@ -171,6 +236,12 @@ def test_report_asset_materialization_apis_consistent(
171236
assert mat.partition == v
172237
elif k == "description":
173238
assert mat.description == v
239+
elif k == "tags":
240+
assert isinstance(v, dict)
241+
tags = mat.tags
242+
assert tags
243+
for tag_key, tag_value in v.items():
244+
assert tags[tag_key] == tag_value
174245
else:
175246
assert False, (
176247
"need to add validation that sample payload content was written successfully"
@@ -181,7 +252,7 @@ def test_report_asset_materialization_apis_consistent(
181252
skip_set = {"self"}
182253
params = [p for p in sig.parameters if p not in skip_set]
183254

184-
KNOWN_DIFF = {"partition", "description"}
255+
KNOWN_DIFF = {"partition", "description", "tags"}
185256

186257
assert set(sample_payload.keys()).difference(set(params)) == KNOWN_DIFF
187258

@@ -322,6 +393,34 @@ def test_report_asset_obs_endpoint(instance: DagsterInstance, test_client: TestC
322393
obs = _assert_stored_obs(instance, my_asset_key)
323394
assert obs.data_version == "fresh"
324395

396+
# user-provided tags (json body); dedicated data_version param wins over a conflicting tag
397+
response = test_client.post(
398+
f"/report_asset_observation/{my_asset_key}",
399+
json={
400+
"data_version": "param_wins",
401+
"tags": {
402+
"dagster/input_data_version/upstream/key": "12345",
403+
"my_tag": "my_value",
404+
DATA_VERSION_TAG: "tag_loses",
405+
},
406+
},
407+
)
408+
assert response.status_code == 200, response.json()
409+
obs = _assert_stored_obs(instance, my_asset_key)
410+
tags = obs.tags
411+
assert tags
412+
assert tags["dagster/input_data_version/upstream/key"] == "12345"
413+
assert tags["my_tag"] == "my_value"
414+
assert tags[DATA_VERSION_TAG] == "param_wins"
415+
416+
# bad tags: not an object
417+
response = test_client.post(
418+
f"/report_asset_observation/{my_asset_key}",
419+
json={"tags": "im_just_a_string"},
420+
)
421+
assert response.status_code == 400
422+
assert "Expected tags to be a json object" in response.json()["error"]
423+
325424

326425
def test_report_asset_observation_apis_consistent(
327426
instance: DagsterInstance, test_client: TestClient
@@ -332,6 +431,7 @@ def test_report_asset_observation_apis_consistent(
332431
"data_version": "so_new",
333432
"partition": "2023-09-23",
334433
"description": "boo",
434+
"tags": {"dagster/input_data_version/up/stream": "42", "my_tag": "my_value"},
335435
}
336436

337437
# sample has entry for all supported params (banking on usage of enum)
@@ -359,6 +459,12 @@ def test_report_asset_observation_apis_consistent(
359459
assert obs.partition == v
360460
elif k == "description":
361461
assert obs.description == v
462+
elif k == "tags":
463+
assert isinstance(v, dict)
464+
tags = obs.tags
465+
assert tags
466+
for tag_key, tag_value in v.items():
467+
assert tags[tag_key] == tag_value
362468
else:
363469
assert False, (
364470
"need to add validation that sample payload content was written successfully"

0 commit comments

Comments
 (0)