Skip to content

Commit ec3193c

Browse files
Small communication protocol fixes
* added more logging to comm protocol * updated documentation * added check for target exports
1 parent 7706b5a commit ec3193c

7 files changed

Lines changed: 194 additions & 87 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2025.10.13T19.31.35.177Z.f12616b4.master
1+
0.1.0+2025.10.13T19.40.55.998Z.5fbaf513.berickson.2025.10.13.comm.protocol

docs/how-to/communication_protocol.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ Submit the flattened DAG to the communication protocol endpoint with runtime par
147147

148148
On success, the response includes export payloads keyed by export name. Inspect `DAGExecutionException` for error details.
149149

150+
The executor validates each requested export before any DAG work begins. If an
151+
export name is unknown - or if its declared `returns` node cannot be found - the
152+
server responds with a `DAGExecutionException` describing the missing export or
153+
node. Surfacing these errors in logs or UI telemetry helps diagnose typos and
154+
stale configuration quickly.
155+
150156
When using integration bindings, call the generated async function with the same parameters.
151157

152158
## 10. Construct Websocket Requests
@@ -183,6 +189,33 @@ The server streams back updates in messages shaped like:
183189

184190
If `rerun_dag_delay` is set, the server automatically re-executes the DAG and pushes updates.
185191

192+
### Manual testing with the generic websocket dashboards
193+
194+
Two helper scripts live in `scripts/` for exercising websocket flows without running a full dashboard UI:
195+
196+
* `generic_websocket_dashboard.py` (Python + `aiohttp`)
197+
* `generic_websocket_dashboard.js` (Node.js + `ws`)
198+
199+
Both scripts ship with a template payload under the `REQUEST` constant. Update the payload to target the exports and parameters you want to test—for example, changing `execution_dag`, `target_exports`, or `kwargs.course_id`.
200+
201+
To run the Python version:
202+
203+
```bash
204+
python scripts/generic_websocket_dashboard.py
205+
```
206+
207+
The script opens a websocket to `/wsapi/communication_protocol`, sends the JSON request, and pretty-prints any responses. Install dependencies with `pip install aiohttp` if needed.
208+
209+
The Node.js version follows the same pattern. After adjusting `REQUEST`, run:
210+
211+
```bash
212+
node scripts/generic_websocket_dashboard.js
213+
```
214+
215+
If you copy the script into a browser console, delete the `require('ws')` line so the native `WebSocket` implementation is used.
216+
217+
Use these scripts to confirm executor behaviour during development—for example, to observe partial updates or to verify that query parameters are wired correctly before embedding a request in a Dash dashboard.
218+
186219
## 11. Iterate and Maintain
187220

188221
* Profile slow queries; large joins may need new helpers or precomputed reducers.

learning_observer/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2025.10.01T17.50.57.101Z.d6047986.master
1+
0.1.0+2025.10.13T19.17.03.311Z.f12616b4.berickson.2025.10.13.comm.protocol

learning_observer/learning_observer/communication_protocol/executor.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ async def handle_select(keys, fields=learning_observer.communication_protocol.qu
446446

447447
# Determine fields to keep based on the current resulting_value if fields is All
448448
if fields == learning_observer.communication_protocol.query.SelectFields.All:
449-
current_fields_to_keep = {key: key for key in resulting_value.keys() if key != 'provenance'}
449+
current_fields_to_keep = {key: key for key in resulting_value.keys() if key != 'provenance'} if resulting_value else {}
450450
else:
451451
current_fields_to_keep = fields_to_keep
452452

@@ -666,10 +666,38 @@ async def execute_dag(endpoint, parameters, functions, target_exports):
666666
667667
See `learning_observer/communication_protocol/test_cases.py` for usage examples.
668668
"""
669-
target_nodes = [endpoint['exports'][key]['returns'] for key in target_exports]
670-
669+
exports = endpoint.get('exports', {})
670+
nodes = endpoint.get('execution_dag', {})
671671
visited = set()
672-
nodes = endpoint['execution_dag']
672+
673+
# --- Resolve targets and collect any obvious errors early ---
674+
target_nodes = []
675+
target_errors = {} # maps target node name -> error dict
676+
677+
for key in target_exports:
678+
if key not in exports:
679+
# Unknown export requested
680+
target_name = f'__missing_export__:{key}'
681+
target_nodes.append(target_name)
682+
target_errors[target_name] = DAGExecutionException(
683+
f'Export `{key}` not found in endpoint.exports.',
684+
inspect.currentframe().f_code.co_name,
685+
{'requested_export': key, 'available_exports': list(exports.keys())}
686+
).to_dict()
687+
continue
688+
689+
target_node = exports[key].get('returns')
690+
if target_node not in nodes:
691+
# Export exists, but its `returns` node is missing from the DAG
692+
target_nodes.append(target_node)
693+
target_errors[target_node] = DAGExecutionException(
694+
f'Target DAG node `{target_node}` not found in execution_dag.',
695+
inspect.currentframe().f_code.co_name,
696+
{'target_node': target_node, 'available_nodes': list(nodes.keys())}
697+
).to_dict()
698+
continue
699+
700+
target_nodes.append(target_node)
673701

674702
async def dispatch_node(node):
675703
"""
@@ -743,10 +771,17 @@ async def visit(node_name):
743771
visited.add(node_name)
744772
return nodes[node_name]
745773

774+
out = {}
775+
for e in target_nodes:
776+
if e in target_errors:
777+
out[e] = _clean_json_via_generator(target_errors[e])
778+
else:
779+
out[e] = _clean_json_via_generator(await visit(e))
780+
return out
781+
746782
# Include execution history in output if operating in development settings
747783
if learning_observer.settings.RUN_MODE == learning_observer.settings.RUN_MODES.DEV:
748784
return {e: _clean_json_via_generator(await visit(e)) for e in target_nodes}
749-
750785
# HACK currently `dashboard.py` relies on the provenance to tell users which
751786
# items need updating, such as John Doe's history essay. This ought to be
752787
# handled by the communication protocol during execution. Once that occurs,

learning_observer/learning_observer/dashboard.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,53 @@ async def websocket_dashboard_handler(request):
587587
Returns:
588588
aiohttp web response.
589589
'''
590+
active_user = await learning_observer.auth.get_active_user(request)
591+
user_context = {
592+
'user_id': (active_user or {}).get('user_id'),
593+
'user_email': (active_user or {}).get('email'),
594+
'user_role': (active_user or {}).get('role'),
595+
}
596+
597+
def _log_protocol_event(event, **extra):
598+
'''
599+
Emit structured debug logs describing websocket activity.
600+
'''
601+
payload = {
602+
'event': event,
603+
'user_id': user_context.get('user_id'),
604+
'user_email': user_context.get('user_email'),
605+
'user_role': user_context.get('user_role'),
606+
'remote': request.remote,
607+
'forwarded_for': request.headers.get('X-Forwarded-For'),
608+
'request_path': str(request.rel_url),
609+
}
610+
payload.update(extra)
611+
try:
612+
debug_log('communication_protocol_event', json.dumps(payload, sort_keys=True))
613+
except TypeError:
614+
# Fall back to logging the raw payload if serialization fails.
615+
debug_log('communication_protocol_event', payload)
616+
617+
def _summarize_query(query):
618+
'''
619+
Provide a compact description of the query for log aggregation.
620+
'''
621+
summary = {}
622+
for key, value in (query or {}).items():
623+
if not isinstance(value, dict):
624+
summary[key] = {'non_dict_value': repr(value)}
625+
continue
626+
execution_dag = value.get('execution_dag')
627+
summary[key] = {
628+
'target_exports': value.get('target_exports', []),
629+
'execution_dag_type': type(execution_dag).__name__,
630+
'execution_dag_name': execution_dag if isinstance(execution_dag, str) else None,
631+
'kwargs': value.get('kwargs', {}),
632+
}
633+
return summary
634+
635+
_log_protocol_event('connection_opened')
636+
590637
ws = aiohttp.web.WebSocketResponse(receive_timeout=0.3)
591638
await ws.prepare(request)
592639
client_query = None
@@ -661,11 +708,15 @@ async def _drive_generator(generator, dag_kwargs, target=None):
661708
try:
662709
received_params = await ws.receive_json()
663710
client_query = received_params
711+
_log_protocol_event(
712+
'query_received',
713+
query_summary=_summarize_query(client_query),
714+
)
664715
# TODO we should validate the client_query structure
665716
except (TypeError, ValueError):
666717
# these Errors may signal a close
667718
if (await ws.receive()).type == aiohttp.WSMsgType.CLOSE:
668-
debug_log("Socket closed!")
719+
_log_protocol_event('connection_closed', reason='client_close_frame')
669720
return aiohttp.web.Response()
670721
except asyncio.exceptions.TimeoutError:
671722
# this is the normal path of the code
@@ -674,7 +725,7 @@ async def _drive_generator(generator, dag_kwargs, target=None):
674725
continue
675726

676727
if ws.closed:
677-
debug_log("Socket closed.")
728+
_log_protocol_event('connection_closed', reason='websocket_closed_flag')
678729
return aiohttp.web.Response()
679730

680731
if client_query != previous_client_query:
Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,48 @@
11
/*
2-
This is test code for our new generic dashboard framework.
2+
Simple Node.js websocket client for exercising the communication protocol.
33
4-
It runs with node.js. We're developing it in node so that we can use
5-
this as a starting point for thinking about a front-end framework, and
6-
perhaps share test code.
7-
8-
We don't have a clean plan for where we'll go (e.g. reuse code versus
9-
prototypes). It's starting as a clone of the Python code with the same
10-
filename.
4+
Update the REQUEST payload to point at the execution DAG and exports you want
5+
to test.
116
*/
127

13-
// var d3 = require('d3');
14-
// d3.text("https://www.google.com", function(d) {console.log(d);});
8+
// Remove this line when running within a browser terminal (i.e. non-Node.js environment)
159
const WebSocket = require('ws');
1610

17-
server = 'ws://localhost:8888/wsapi/generic_dashboard'
18-
19-
messages = [
20-
{
21-
"action": "subscribe",
22-
"keys": [{
23-
"source" : "da_timeline.visualize.handle_event",
24-
"KeyField.STUDENT": "guest-225d890e93a6b04c0aefe515b9d2dac9"
25-
}],
26-
"refresh": [0.5, "seconds"]
27-
},
28-
{
29-
"action": "subscribe",
30-
"keys": [{
31-
"source" : "da_timeline.visualize.handle_event",
32-
"KeyField.STUDENT": "INVALID-STUDENT"
33-
}],
34-
"refresh": [2, "seconds"]
35-
},
36-
{
37-
"action": "start"
38-
}
39-
]
40-
41-
socket = new WebSocket(server);
42-
socket.on('message', msg => console.log(msg.toString()));
11+
const SERVER = 'ws://localhost:8888/wsapi/communication_protocol';
4312

44-
socket.onopen = function() {
45-
console.log("Open");
46-
for(var i=0; i<messages.length; i++) {
47-
socket.send(JSON.stringify(messages[i]));
13+
const REQUEST = {
14+
docs_request: {
15+
execution_dag: 'writing_observer',
16+
target_exports: ['docs_with_roster'],
17+
kwargs: {
18+
course_id: 'COURSE-123',
4819
}
20+
}
4921
};
50-
51-
// We can comment in/out console.log(`d`) based on whether we want a giant amount of debug data
52-
socket.onerror = function(d) { console.log("Error"); console.log(d); };
53-
socket.onclose = function(d) { console.log("Close"); /* console.log(d); */ };
22+
const socket = new WebSocket(SERVER);
23+
24+
socket.on('open', () => {
25+
console.log('Open');
26+
socket.send(JSON.stringify(REQUEST));
27+
});
28+
29+
socket.on('message', (msg) => {
30+
try {
31+
const parsed = JSON.parse(msg.toString());
32+
console.log(parsed);
33+
} catch (error) {
34+
console.log(msg.toString());
35+
}
36+
});
37+
38+
socket.on('error', (err) => {
39+
console.log('Error');
40+
console.log(err);
41+
});
42+
43+
socket.on('close', (event) => {
44+
console.log('Close');
45+
if (event) {
46+
console.log(event);
47+
}
48+
});
Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,41 @@
1-
'''
2-
This is a test script for a web socket interaction.
3-
'''
1+
"""Simple websocket client for manual communication protocol testing."""
42

53
import aiohttp
64
import asyncio
5+
import json
76

8-
messages = [
9-
{
10-
"action": "subscribe",
11-
"keys": [{
12-
"source": "da_timeline.visualize.handle_event",
13-
"KeyField.STUDENT": "guest-225d890e93a6b04c0aefe515b9d2dac9"
14-
}],
15-
"refresh": [0.5, "seconds"]
16-
},
17-
{
18-
"action": "subscribe",
19-
"keys": [{
20-
"source": "da_timeline.visualize.handle_event",
21-
"KeyField.STUDENT": "INVALID-STUDENT"
22-
}],
23-
"refresh": [2, "seconds"]
24-
},
25-
{
26-
"action": "start"
7+
# Example request payload for the communication protocol websocket. Adjust the
8+
# execution_dag, target_exports, and kwargs to match the reducers you want to
9+
# exercise.
10+
REQUEST = {
11+
"docs_request": {
12+
"execution_dag": "writing_observer",
13+
"target_exports": ["docs_with_roster"],
14+
"kwargs": {
15+
"course_id": 12345678901
16+
},
2717
}
28-
]
18+
}
2919

3020

3121
async def main():
3222
async with aiohttp.ClientSession() as session:
33-
async with session.ws_connect('http://localhost:8888/wsapi/generic_dashboard', timeout=0.5) as ws:
34-
for message in messages:
35-
await ws.send_json(message)
36-
23+
async with session.ws_connect(
24+
"http://localhost:8888/wsapi/communication_protocol",
25+
timeout=5,
26+
) as ws:
27+
await ws.send_json(REQUEST)
3728
async for msg in ws:
38-
print(msg.type)
3929
if msg.type == aiohttp.WSMsgType.TEXT:
40-
print("Message")
41-
print(msg.data)
30+
try:
31+
parsed = json.loads(msg.data)
32+
except json.JSONDecodeError:
33+
parsed = msg.data
34+
print(json.dumps(parsed, indent=2))
4235
elif msg.type == aiohttp.WSMsgType.ERROR:
43-
print("Error")
44-
print(msg)
36+
print("Error received from websocket:", msg)
4537
break
46-
return True
4738

48-
asyncio.run(main())
39+
40+
if __name__ == '__main__':
41+
asyncio.run(main())

0 commit comments

Comments
 (0)