Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pylock.toml
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,12 @@ version = "0.2.13"
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", upload-time = 2024-01-06T02:10:57Z, size = 101301, hashes = { sha256 = "72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" } }
wheels = [{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", upload-time = 2024-01-06T02:10:55Z, size = 34166, hashes = { sha256 = "3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859" } }]

[[packages]]
name = "websocket-client"
version = "1.9.0"
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", upload-time = 2025-10-07T21:16:36Z, size = 70576, hashes = { sha256 = "9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98" } }
wheels = [{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", upload-time = 2025-10-07T21:16:34Z, size = 82616, hashes = { sha256 = "af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" } }]

[[packages]]
name = "wheel"
version = "0.45.1"
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"pip==25.3",
"pluggy==1.6.0",
"prompt-toolkit==3.0.51",
"protobuf>=3.20,<7",
"pydantic==2.12.5",
"requests==2.32.4",
"requirements-parser==0.13.0",
Expand All @@ -46,6 +47,7 @@ dependencies = [
"tomlkit==0.13.3",
"typer==0.17.3",
"urllib3>=2.6.3,<3",
"websocket-client>=1.6.0,<2",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
Expand Down
1 change: 1 addition & 0 deletions snyk/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@ tzdata==2025.2 ; sys_platform == 'win32'
tzlocal==5.3.1
urllib3==2.6.3
wcwidth==0.2.13
websocket-client==1.9.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will need to ask around if we need a formal approval to add a new package

wheel==0.45.1
zipp==3.23.0 ; python_full_version < '3.12'
85 changes: 84 additions & 1 deletion src/snowflake/cli/_plugins/streamlit/commands.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) 2024 Snowflake Inc.
# Copyright (c) 2026 Snowflake Inc.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it needed to update the year? I'd rather keep it as previous value

#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -24,6 +24,10 @@
add_object_command_aliases,
scope_option,
)
from snowflake.cli._plugins.streamlit.log_streaming import (
stream_logs,
validate_spcs_v2_runtime,
)
from snowflake.cli._plugins.streamlit.manager import StreamlitManager
from snowflake.cli._plugins.streamlit.streamlit_entity import StreamlitEntity
from snowflake.cli._plugins.workspace.context import ActionContext, WorkspaceContext
Expand All @@ -33,6 +37,7 @@
with_project_definition,
)
from snowflake.cli.api.commands.flags import (
IdentifierType,
PruneOption,
ReplaceOption,
entity_argument,
Expand Down Expand Up @@ -215,6 +220,84 @@ def get_url(
return MessageResult(url)


@app.command("logs", requires_connection=True)
@with_project_definition(is_optional=True)
def streamlit_logs(
entity_id: str = entity_argument("streamlit"),
name: FQN = typer.Option(
None,
"--name",
help="Fully qualified name of the Streamlit app (e.g. my_app, schema.my_app, or db.schema.my_app). "
"Overrides the project definition when provided.",
click_type=IdentifierType(),
),
tail: int = typer.Option(
100,
"--tail",
"-n",
min=0,
max=1000, # server-side buffer size limit (see logs_service.proto)
help="Number of historical log lines to fetch. Use 0 for live logs only.",
),
**options,
) -> CommandResult:
"""
Streams live logs from a deployed Streamlit app to your terminal.

Reads the Streamlit app name from the project definition file (snowflake.yml)
or from the --name option. Connects to the app's developer log service via
WebSocket and prints log entries in real time. Press Ctrl+C to stop streaming.

Log streaming requires SPCSv2 runtime.
"""
cli_context = get_cli_context()
conn = cli_context.connection

if name is not None:
if entity_id is not None:
raise ClickException(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use CliError and it's subclasses

"Cannot specify both --name and an entity ID. "
"Use --name to identify the app directly, or use an "
"entity ID to reference a snowflake.yml definition."
)
# --name flag provided: resolve FQN and validate via server-side DESCRIBE
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you find those comments useful then maybe it would be better to convert those into log.debug messages? That way we could get additional data when debugging client issues

fqn = name.using_connection(conn)
validate_spcs_v2_runtime(conn, str(fqn))
else:
# No --name: require project definition
pd = cli_context.project_definition
if pd is None:
raise ClickException(
"No Streamlit app specified. Provide --name or run from a "
"directory with a snowflake.yml project definition."
)
if not pd.meets_version_requirement("2"):
if not pd.streamlit:
raise NoProjectDefinitionError(
project_type="streamlit", project_root=cli_context.project_root
)
pd = convert_project_definition_to_v2(cli_context.project_root, pd)

entity_model = get_entity_for_operation(
cli_context=cli_context,
entity_id=entity_id,
project_definition=pd,
entity_type=ObjectType.STREAMLIT.value.cli_name,
)

fqn = entity_model.fqn.using_connection(conn)
# Validate SPCSv2 runtime via server-side DESCRIBE (same path as --name)
validate_spcs_v2_runtime(conn, str(fqn))

stream_logs(
conn=conn,
fqn=str(fqn),
tail_lines=tail,
json_output=cli_context.output_format.is_json,
)
return MessageResult("Log streaming ended.")


def _get_current_workspace_context():
ctx = get_cli_context()

Expand Down
228 changes: 228 additions & 0 deletions src/snowflake/cli/_plugins/streamlit/log_streaming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# Copyright (c) 2026 Snowflake Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
WebSocket log streaming client for Streamlit developer logs.

Connects to the Streamlit container runtime's developer log service
via WebSocket and streams log entries in real time.
"""

from __future__ import annotations

import json
import logging
import sys
from dataclasses import dataclass

import websocket
from click import ClickException
from google.protobuf.message import DecodeError
from snowflake.cli._plugins.streamlit.proto_codec import (
decode_log_entry,
encode_stream_logs_request,
)
from snowflake.cli._plugins.streamlit.streamlit_entity_model import (
SPCS_RUNTIME_V2_NAME,
)
from snowflake.cli.api.console import cli_console
from snowflake.connector import SnowflakeConnection

log = logging.getLogger(__name__)

# Timeout for each ws.recv_data() call — mirrors the Go client's 90-second
# read deadline. When no log entry arrives within this window, we re-issue
# recv_data() so the loop stays responsive to KeyboardInterrupt.
_WS_RECV_TIMEOUT_SECONDS = 90

_HANDSHAKE_TIMEOUT_SECONDS = 10


@dataclass
class DeveloperApiToken:
token: str
resource_uri: str


def get_developer_api_token(conn: SnowflakeConnection, fqn: str) -> DeveloperApiToken:
"""
Calls SYSTEM$GET_STREAMLIT_DEVELOPER_API_TOKEN and returns a
DeveloperApiToken with the token and resource URI.
"""
if "'" in fqn:
raise ClickException(
f"Invalid Streamlit app name: {fqn}. Name must not contain single quotes."
)

query = f"CALL SYSTEM$GET_STREAMLIT_DEVELOPER_API_TOKEN('{fqn}', false);"
log.debug("Fetching developer API token for %s", fqn)

cursor = conn.cursor()
try:
cursor.execute(query)
row = cursor.fetchone()
if not row:
raise ClickException(
"Empty response from SYSTEM$GET_STREAMLIT_DEVELOPER_API_TOKEN"
)
raw = row[0]
finally:
cursor.close()

try:
resp = json.loads(raw)
except (json.JSONDecodeError, TypeError) as e:
raise ClickException(f"Failed to parse token response: {e}") from e

token = resp.get("token", "")
resource_uri = resp.get("resourceUri", "")

if not token:
raise ClickException("Empty token in developer API response")
if not resource_uri:
raise ClickException("Empty resourceUri in developer API response")

log.debug("Resource URI: %s", resource_uri)
return DeveloperApiToken(token=token, resource_uri=resource_uri)


def build_ws_url(resource_uri: str) -> str:
"""Convert resource URI to WebSocket URL and append /logs path."""
ws_url = resource_uri.replace("https://", "wss://", 1).replace(
"http://", "ws://", 1
)
return ws_url.rstrip("/") + "/logs"


def validate_spcs_v2_runtime(conn: SnowflakeConnection, fqn: str) -> None:
"""
Run DESCRIBE STREAMLIT and verify the app uses SPCSv2 runtime.

Raises ClickException if the app does not use the SPCS Runtime V2
(required for log streaming).
"""
cursor = conn.cursor()
try:
# fqn is already validated by IdentifierType / FQN.using_connection —
# DESCRIBE uses identifier syntax, not string literals, so no
# single-quote injection risk.
cursor.execute(f"DESCRIBE STREAMLIT {fqn}")
row = cursor.fetchone()
description = cursor.description
finally:
cursor.close()

if not row or not description:
raise ClickException(
f"Could not describe Streamlit app {fqn}. "
"Verify the app exists and you have access."
)

# Build column-name -> value mapping from cursor.description
columns = {desc[0].lower(): val for desc, val in zip(description, row)}
runtime_name = columns.get("runtime_name")

if runtime_name != SPCS_RUNTIME_V2_NAME:
raise ClickException(
f"Log streaming is only supported for Streamlit apps running on "
f"SPCSv2 runtime ({SPCS_RUNTIME_V2_NAME}). "
f"App '{fqn}' has runtime_name='{runtime_name}'."
)


def stream_logs(
conn: SnowflakeConnection,
fqn: str,
tail_lines: int = 100,
json_output: bool = False,
) -> None:
"""
Connect to the Streamlit developer log streaming WebSocket and print
log entries to stdout until interrupted.

When *json_output* is True each log entry is emitted as a single-line
JSON object (JSONL), suitable for piping to ``jq`` or other tools.
"""
# 1. Get token
cli_console.step("Fetching developer API token...")
token_info = get_developer_api_token(conn, fqn)

# 2. Build WebSocket URL
ws_url = build_ws_url(token_info.resource_uri)
cli_console.step(f"Connecting to log stream: {ws_url}")

# 3. Connect
# NOTE: Do not log `header` — it contains the auth token. Also be aware
# that websocket.enableTrace(True) will dump headers to stderr.
header = [f'Authorization: Snowflake Token="{token_info.token}"']
ws = websocket.WebSocket()
ws.timeout = _WS_RECV_TIMEOUT_SECONDS
streaming = False

try:
try:
ws.connect(ws_url, header=header, timeout=_HANDSHAKE_TIMEOUT_SECONDS)
except Exception as e:
raise ClickException(f"Failed to connect to log stream: {e}") from e

# 4. Send StreamLogsRequest
ws.send_binary(encode_stream_logs_request(tail_lines))
log.debug("Sent StreamLogsRequest with tail_lines=%d", tail_lines)

cli_console.step(f"Streaming logs (tail={tail_lines}). Press Ctrl+C to stop.")
sys.stdout.write("---\n")
sys.stdout.flush()
streaming = True

# 5. Read loop
while True:
try:
opcode, data = ws.recv_data()
except websocket.WebSocketTimeoutException:
# No message within the timeout window — loop back so we
# stay responsive to KeyboardInterrupt.
continue
except websocket.WebSocketConnectionClosedException:
log.debug("WebSocket connection closed by server")
break
except (websocket.WebSocketException, OSError) as e:
log.debug("WebSocket recv error: %s", e)
break

if opcode == websocket.ABNF.OPCODE_BINARY:
try:
entry = decode_log_entry(data)
except (DecodeError, ValueError) as e:
log.warning("Failed to decode log entry: %s", e)
continue
if json_output:
sys.stdout.write(json.dumps(entry.to_dict()) + "\n")
else:
sys.stdout.write(entry.format_line() + "\n")
sys.stdout.flush()
elif opcode == websocket.ABNF.OPCODE_CLOSE:
break
elif opcode == websocket.ABNF.OPCODE_PING:
ws.pong(data)

except KeyboardInterrupt:
pass
finally:
try:
ws.close(status=websocket.STATUS_NORMAL)
except Exception as e:
log.debug("Error closing WebSocket: %s", e)
if streaming:
sys.stdout.write("\n--- Log streaming stopped.\n")
sys.stdout.flush()
Comment on lines +183 to +228
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're not sanitizing the data coming server with sanitize_for_terminal(). Not sure if users can emit custom logs in their app code, but if yes, then a malicious app can cause issues

13 changes: 13 additions & 0 deletions src/snowflake/cli/_plugins/streamlit/proto/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (c) 2026 Snowflake Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
Loading