mikeboone Claude Fable 5 commited on
Commit
04fcaca
·
1 Parent(s): 236e1a7

feat(logging): capture full run payload in session_logs on every run

Browse files

Every run (App tab + Chat tab) now logs its complete payload to Supabase
session_logs in meta.payload of the 'run started' events:
- raw GO form inputs (controller._run_payload set in defined_go)
- resolved company/use case/vertical/line/function/context
- secret-redacted settings snapshot

Attached in _create_run_loggers() (guaranteed even when a run dies before
research) and again in run_research_streaming() once fully resolved.
New sanitize_payload() in session_logger.py redacts key/secret/password/
token/credential/auth values and truncates >2000-char strings; 5 tests in
tests/test_run_payload_logging.py. Chat-tab runs reset stale App-tab
_run_source/_run_payload at initialization. Query guide in
dev_notes/logging_channels.md (local docs, not tracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

chat_interface.py CHANGED
@@ -556,14 +556,46 @@ I'll research a company, build a Snowflake schema, generate realistic data, and
556
  test_tag=tag,
557
  model=resolved_model,
558
  model_setting=model_setting,
 
559
  )
560
  return self._session_logger
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
 
562
  def process_chat_message(self, message, chat_history, current_stage, current_model, company, use_case):
563
  """
564
  Process user message and return updated chat history and state (with streaming)
565
  Returns: (chat_history, current_stage, current_model, company, use_case, next_textbox_value)
566
  """
 
 
 
 
 
 
 
567
  # Pipeline starts from awaiting_context; always give it a fresh run log.
568
  self._create_run_loggers(force=(current_stage == 'awaiting_context'))
569
  _slog = self._session_logger
@@ -2095,6 +2127,7 @@ To change settings, use:
2095
  additional_context=(getattr(self, 'generic_use_case_context', '') or '')[:500],
2096
  model=resolved_model,
2097
  model_setting=model_setting,
 
2098
  )
2099
  password_block = self._temporary_password_block_message()
2100
  if password_block:
@@ -6302,6 +6335,26 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6302
  if share_with is not None:
6303
  controller.settings['share_with'] = share_with
6304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6305
  # Derive company name from URL or line+function label
6306
  if use_url and url.strip():
6307
  raw_company = url.strip()
 
556
  test_tag=tag,
557
  model=resolved_model,
558
  model_setting=model_setting,
559
+ payload=self._snapshot_run_payload(),
560
  )
561
  return self._session_logger
562
+
563
+ def _snapshot_run_payload(self):
564
+ """
565
+ One JSON-safe record of everything that defines this run.
566
+
567
+ Logged into session_logs meta on every run so Run History and ad-hoc
568
+ queries can always answer "what was run": the raw GO form inputs
569
+ (App tab), the resolved use case, and a secret-redacted snapshot of
570
+ the controller settings. Secrets never leave the process — see
571
+ sanitize_payload() in session_logger.py.
572
+ """
573
+ from session_logger import sanitize_payload
574
+ return sanitize_payload({
575
+ 'interface': getattr(self, '_run_source', 'chat'),
576
+ 'company': getattr(self, 'pending_generic_company', '') or self.settings.get('company', ''),
577
+ 'use_case': getattr(self, 'pending_generic_use_case', '') or self.settings.get('use_case', ''),
578
+ 'vertical': getattr(self, 'vertical', None),
579
+ 'line': getattr(self, 'line', None),
580
+ 'function': getattr(self, 'function', None),
581
+ 'is_custom': getattr(self, 'is_generic_use_case', False),
582
+ 'additional_context': getattr(self, 'generic_use_case_context', '') or '',
583
+ 'form': getattr(self, '_run_payload', None),
584
+ 'settings': dict(self.settings),
585
+ })
586
 
587
  def process_chat_message(self, message, chat_history, current_stage, current_model, company, use_case):
588
  """
589
  Process user message and return updated chat history and state (with streaming)
590
  Returns: (chat_history, current_stage, current_model, company, use_case, next_textbox_value)
591
  """
592
+ # Chat-tab runs enter at 'initialization' (App tab jumps straight to
593
+ # 'awaiting_context' after stashing its form payload) — reset any
594
+ # leftover App-tab run metadata so the logged payload matches this run.
595
+ if current_stage == 'initialization':
596
+ self._run_source = 'chat'
597
+ self._run_payload = None
598
+
599
  # Pipeline starts from awaiting_context; always give it a fresh run log.
600
  self._create_run_loggers(force=(current_stage == 'awaiting_context'))
601
  _slog = self._session_logger
 
2127
  additional_context=(getattr(self, 'generic_use_case_context', '') or '')[:500],
2128
  model=resolved_model,
2129
  model_setting=model_setting,
2130
+ payload=self._snapshot_run_payload(),
2131
  )
2132
  password_block = self._temporary_password_block_message()
2133
  if password_block:
 
6335
  if share_with is not None:
6336
  controller.settings['share_with'] = share_with
6337
 
6338
+ # Capture the raw GO form payload so the run logger records exactly
6339
+ # what was submitted (logged secret-redacted via _snapshot_run_payload)
6340
+ controller._run_payload = {
6341
+ 'vertical': vertical,
6342
+ 'line': line,
6343
+ 'function': function_clean,
6344
+ 'url': url,
6345
+ 'use_url': use_url,
6346
+ 'additional_info': additional_info,
6347
+ 'model': model,
6348
+ 'ts_environment': env_label,
6349
+ 'liveboard_name': lb_name,
6350
+ 'data_size': data_size,
6351
+ 'geo_scope': geo_scope,
6352
+ 'tag_name': tag_name,
6353
+ 'column_naming': col_naming,
6354
+ 'object_prefix': obj_prefix,
6355
+ 'share_with': share_with,
6356
+ }
6357
+
6358
  # Derive company name from URL or line+function label
6359
  if use_url and url.strip():
6360
  raw_company = url.strip()
session_logger.py CHANGED
@@ -47,6 +47,44 @@ from pathlib import Path
47
  from typing import Any, Optional
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  # ---------------------------------------------------------------------------
51
  # SessionLogger class
52
  # ---------------------------------------------------------------------------
 
47
  from typing import Any, Optional
48
 
49
 
50
+ # ---------------------------------------------------------------------------
51
+ # Payload sanitization
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _SECRET_KEY_RE = re.compile(r"key|secret|password|token|credential|auth", re.IGNORECASE)
55
+ _MAX_PAYLOAD_STR = 2000
56
+
57
+
58
+ def sanitize_payload(value: Any, _depth: int = 0) -> Any:
59
+ """
60
+ Return a JSON-safe copy of `value` suitable for the session_logs meta column.
61
+
62
+ - Dict values whose key looks secret (key/secret/password/token/credential/auth)
63
+ are replaced with a length marker, never logged.
64
+ - Strings longer than 2000 chars are truncated.
65
+ - Anything non-JSON-serializable is coerced to str.
66
+ """
67
+ if _depth > 6:
68
+ return str(value)
69
+ if isinstance(value, dict):
70
+ out = {}
71
+ for k, v in value.items():
72
+ if _SECRET_KEY_RE.search(str(k)) and isinstance(v, str) and v:
73
+ out[str(k)] = f"<redacted {len(v)} chars>"
74
+ else:
75
+ out[str(k)] = sanitize_payload(v, _depth + 1)
76
+ return out
77
+ if isinstance(value, (list, tuple)):
78
+ return [sanitize_payload(v, _depth + 1) for v in value]
79
+ if isinstance(value, str):
80
+ if len(value) > _MAX_PAYLOAD_STR:
81
+ return value[:_MAX_PAYLOAD_STR] + f"… <truncated, {len(value)} chars total>"
82
+ return value
83
+ if isinstance(value, (int, float, bool)) or value is None:
84
+ return value
85
+ return str(value)
86
+
87
+
88
  # ---------------------------------------------------------------------------
89
  # SessionLogger class
90
  # ---------------------------------------------------------------------------
tests/test_run_payload_logging.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for run-payload logging.
3
+
4
+ Covers sanitize_payload() in session_logger.py — the guard that lets the full
5
+ run payload go into the Supabase session_logs meta column on every run without
6
+ ever leaking secrets (trusted-auth keys, API keys, passwords).
7
+ """
8
+
9
+ import json
10
+
11
+ from session_logger import sanitize_payload
12
+
13
+
14
+ def test_secret_keys_are_redacted():
15
+ payload = {
16
+ 'thoughtspot_trusted_auth_key': 'super-secret-value-123',
17
+ 'openai_api_key': 'sk-abc',
18
+ 'password': 'hunter2',
19
+ 'some_token': 'tok_xyz',
20
+ 'thoughtspot_url': 'https://demo.thoughtspot.cloud',
21
+ 'company': 'Acme',
22
+ }
23
+ out = sanitize_payload(payload)
24
+ dumped = json.dumps(out)
25
+ assert 'super-secret-value-123' not in dumped
26
+ assert 'sk-abc' not in dumped
27
+ assert 'hunter2' not in dumped
28
+ assert 'tok_xyz' not in dumped
29
+ # Non-secret values pass through untouched
30
+ assert out['thoughtspot_url'] == 'https://demo.thoughtspot.cloud'
31
+ assert out['company'] == 'Acme'
32
+ # Redacted values carry a length marker, not the secret
33
+ assert out['thoughtspot_trusted_auth_key'].startswith('<redacted')
34
+
35
+
36
+ def test_nested_secrets_are_redacted():
37
+ payload = {'settings': {'inner': {'auth_key': 'deep-secret'}}}
38
+ out = sanitize_payload(payload)
39
+ assert 'deep-secret' not in json.dumps(out)
40
+
41
+
42
+ def test_long_strings_are_truncated():
43
+ out = sanitize_payload({'ddl': 'x' * 5000})
44
+ assert len(out['ddl']) < 2100
45
+ assert 'truncated' in out['ddl']
46
+
47
+
48
+ def test_json_safe_output():
49
+ class Weird:
50
+ def __repr__(self):
51
+ return 'WeirdObject'
52
+
53
+ payload = {
54
+ 'obj': Weird(),
55
+ 'tuple': (1, 2),
56
+ 'none': None,
57
+ 'flag': True,
58
+ 'num': 3.5,
59
+ 'list': [{'k': 'v'}, 'plain'],
60
+ }
61
+ out = sanitize_payload(payload)
62
+ # Must round-trip through JSON without default= hacks (Supabase insert path)
63
+ json.dumps(out)
64
+ assert out['obj'] == 'WeirdObject'
65
+ assert out['tuple'] == [1, 2]
66
+ assert out['none'] is None
67
+
68
+
69
+ def test_typical_run_payload_shape():
70
+ """A realistic snapshot like _snapshot_run_payload() produces."""
71
+ snapshot = {
72
+ 'interface': 'app_defined',
73
+ 'company': 'https://nike.com',
74
+ 'use_case': 'Retail Sales Performance',
75
+ 'vertical': 'Retail',
76
+ 'line': 'Retail',
77
+ 'function': 'Sales',
78
+ 'is_custom': False,
79
+ 'additional_context': '',
80
+ 'form': {
81
+ 'vertical': 'Retail', 'line': 'Retail', 'function': 'Sales',
82
+ 'url': 'https://nike.com', 'use_url': True, 'additional_info': '',
83
+ 'model': 'GPT-5', 'ts_environment': 'SE Demo', 'liveboard_name': None,
84
+ 'data_size': 'Medium', 'geo_scope': 'USA Only', 'tag_name': '',
85
+ 'column_naming': 'snake_case', 'object_prefix': '', 'share_with': '',
86
+ },
87
+ 'settings': {
88
+ 'model': 'GPT-5',
89
+ 'thoughtspot_trusted_auth_key': 'SECRET',
90
+ 'fact_table_size': '10000',
91
+ },
92
+ }
93
+ out = sanitize_payload(snapshot)
94
+ assert 'SECRET' not in json.dumps(out)
95
+ assert out['form']['data_size'] == 'Medium'
96
+ assert out['settings']['fact_table_size'] == '10000'