Spaces:
Running
feat(logging): capture full run payload in session_logs on every run
Browse filesEvery 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 +53 -0
- session_logger.py +38 -0
- tests/test_run_payload_logging.py +96 -0
|
@@ -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()
|
|
@@ -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 |
# ---------------------------------------------------------------------------
|
|
@@ -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'
|