Skip to content

Commit f320889

Browse files
authored
Merge pull request #138 from TimeToBuildBob/feat/merge-subwatcher-fields
feat(transform): add merge_subwatcher_fields — general subwatcher field enrichment
2 parents 2859a09 + e83b17f commit f320889

5 files changed

Lines changed: 393 additions & 0 deletions

File tree

aw_query/functions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
flood,
2424
limit_events,
2525
merge_events_by_keys,
26+
merge_subwatcher_fields,
2627
period_union,
2728
simplify_string,
2829
sort_by_duration,
@@ -227,6 +228,17 @@ def q2_merge_events_by_keys(events: list, keys: list) -> List[Event]:
227228
return merge_events_by_keys(events, keys)
228229

229230

231+
@q2_function(merge_subwatcher_fields)
232+
@q2_typecheck
233+
def q2_merge_subwatcher_fields(
234+
base_events: list, subwatcher_events: list, keys: list, conflict: str = "base_wins"
235+
) -> List[Event]:
236+
try:
237+
return merge_subwatcher_fields(base_events, subwatcher_events, keys, conflict)
238+
except ValueError as exc:
239+
raise QueryFunctionException(str(exc)) from None
240+
241+
230242
@q2_function(chunk_events_by_key)
231243
@q2_typecheck
232244
def q2_chunk_events_by_key(events: list, key: str) -> List[Event]:

aw_transform/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
limit_events,
1212
)
1313
from .split_url_events import split_url_events
14+
from .merge_subwatcher_fields import merge_subwatcher_fields
1415
from .simplify import simplify_string
1516
from .flood import flood
1617
from .classify import categorize, tag, Rule
@@ -33,6 +34,7 @@
3334
"heartbeat_reduce",
3435
"heartbeat_merge",
3536
"merge_events_by_keys",
37+
"merge_subwatcher_fields",
3638
"chunk_events_by_key",
3739
"limit_events",
3840
"filter_keyvals",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import logging
2+
from copy import deepcopy
3+
from typing import List, Optional, Tuple
4+
5+
from aw_core.models import Event
6+
from timeslot import Timeslot
7+
8+
from .filter_period_intersect import _get_event_period
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def merge_subwatcher_fields(
14+
base_events: List[Event],
15+
subwatcher_events: List[Event],
16+
keys: List[str],
17+
conflict: str = "base_wins",
18+
) -> List[Event]:
19+
"""
20+
Split each event in *base_events* on overlapping subwatcher boundaries and
21+
copy the named *keys* from the matching subwatcher event into each segment's
22+
``data`` dict.
23+
24+
Unlike the ``concat`` workaround, this does not fabricate extra duration.
25+
App/title/duration aggregations stay correct because the output still covers
26+
exactly the same total time as *base_events*; only the segmentation changes
27+
where subwatcher fields actually change.
28+
29+
This is the backend primitive that lets every client (webui, native UIs,
30+
exporters) categorize by subwatcher fields (browser ``url``/``$domain``;
31+
editor ``project``/``file``/``language``) without bespoke per-watcher
32+
client-side code.
33+
34+
Args:
35+
base_events: The canonical window/afk-filtered stream to enrich.
36+
subwatcher_events: Events from a subwatcher bucket (e.g. aw-watcher-vim,
37+
aw-watcher-web). Should already be clipped via
38+
``filter_period_intersect`` before passing here.
39+
keys: Which keys to copy from the subwatcher event into the base event.
40+
Keys already present in the base event are left untouched when
41+
``conflict="base_wins"`` (default).
42+
conflict: ``"base_wins"`` (default) — base event's existing keys are
43+
never overwritten; subwatcher fields are purely additive.
44+
``"sub_wins"`` — subwatcher fields overwrite base fields.
45+
46+
Returns:
47+
A new list of base event segments with subwatcher fields injected.
48+
Events in *base_events* that have no overlapping subwatcher event are
49+
returned with their original data unchanged.
50+
51+
Example::
52+
53+
window_events = query_bucket(bid_window)
54+
editor_events = flood(query_bucket(bid_editor))
55+
editor_events = filter_period_intersect(editor_events, window_events)
56+
window_events = merge_subwatcher_fields(
57+
window_events, editor_events, ["project", "file", "language"]
58+
)
59+
# Now categorize(window_events, ...) can match on "project"/"file"
60+
61+
Note on overlap:
62+
Base events are split at the clipped subwatcher boundaries so each
63+
output segment only carries the subwatcher fields that were actually
64+
present during that slice of time. If multiple subwatcher events cover
65+
the same slice, the most recent one wins so a later transition does not
66+
get smeared backward by an older, longer pulse.
67+
"""
68+
if conflict not in ("base_wins", "sub_wins"):
69+
raise ValueError(
70+
f"conflict must be 'base_wins' or 'sub_wins', got {conflict!r}"
71+
)
72+
if not subwatcher_events or not keys:
73+
return [deepcopy(e) for e in base_events]
74+
75+
# Build a sorted copy so we can do a linear scan
76+
sub_sorted = sorted(subwatcher_events, key=lambda e: e.timestamp)
77+
78+
result: List[Event] = []
79+
for base in base_events:
80+
base_period = _get_event_period(base)
81+
overlapping: List[Tuple[Event, Timeslot]] = []
82+
boundaries = {base_period.start, base_period.end}
83+
84+
for sub in sub_sorted:
85+
sub_period = _get_event_period(sub)
86+
# Once sub starts after base ends we can stop
87+
if sub_period.start >= base_period.end:
88+
break
89+
# Skip sub events that end before base starts
90+
if sub_period.end <= base_period.start:
91+
continue
92+
ip = base_period.intersection(sub_period)
93+
if ip:
94+
overlapping.append((sub, sub_period))
95+
boundaries.add(ip.start)
96+
boundaries.add(ip.end)
97+
98+
if not overlapping:
99+
result.append(deepcopy(base))
100+
continue
101+
102+
boundary_points = sorted(boundaries)
103+
base_segments: List[Event] = []
104+
for start, end in zip(boundary_points, boundary_points[1:]):
105+
segment_period = Timeslot(start, end)
106+
best_sub: Optional[Event] = None
107+
best_sub_period: Optional[Timeslot] = None
108+
109+
for sub, sub_period in overlapping:
110+
if not segment_period.intersection(sub_period):
111+
continue
112+
113+
# Later subwatcher events should supersede older overlapping
114+
# pulses on the shared slice instead of letting stale data linger.
115+
if (
116+
best_sub is None
117+
or sub.timestamp > best_sub.timestamp
118+
or (
119+
sub.timestamp == best_sub.timestamp
120+
and best_sub_period is not None
121+
and sub_period.end > best_sub_period.end
122+
)
123+
):
124+
best_sub = sub
125+
best_sub_period = sub_period
126+
127+
enriched = deepcopy(base)
128+
enriched.timestamp = start
129+
enriched.duration = end - start
130+
131+
if best_sub is not None:
132+
for key in keys:
133+
if key in best_sub.data:
134+
if conflict == "base_wins" and key in enriched.data:
135+
continue
136+
enriched.data[key] = deepcopy(best_sub.data[key])
137+
138+
if (
139+
base_segments
140+
and base_segments[-1].timestamp + base_segments[-1].duration
141+
== enriched.timestamp
142+
and base_segments[-1].data == enriched.data
143+
):
144+
base_segments[-1].duration += enriched.duration
145+
else:
146+
base_segments.append(enriched)
147+
148+
result.extend(base_segments)
149+
150+
return result

tests/test_query2.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,21 @@ def test_query2_function_invalid_argument_count():
323323
query(qname, example_query, starttime, endtime, ds)
324324

325325

326+
def test_query2_merge_subwatcher_fields_invalid_conflict():
327+
ds = mock_ds
328+
qname = "asd"
329+
starttime = iso8601.parse_date("1970-01-01")
330+
endtime = iso8601.parse_date("1970-01-02")
331+
example_query = """
332+
base = [];
333+
sub = [];
334+
RETURN = merge_subwatcher_fields(base, sub, ["project"], "invalid");
335+
"""
336+
337+
with pytest.raises(QueryFunctionException, match="conflict must be"):
338+
query(qname, example_query, starttime, endtime, ds)
339+
340+
326341
@pytest.mark.parametrize("datastore", param_datastore_objects())
327342
def test_query2_function_in_function(datastore):
328343
qname = "asd"

0 commit comments

Comments
 (0)