Skip to content

Commit f3894e3

Browse files
Add snow logs command (#2130)
* Solution * Fixes * Test fix * Test fix * Test fix
1 parent fcc42c7 commit f3894e3

File tree

12 files changed

+622
-5
lines changed

12 files changed

+622
-5
lines changed

RELEASE-NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
## New additions
2222
* Added `--prune` flag to `deploy` commands, which removes files that exist in the stage,
2323
but not in the local filesystem.
24+
* Added `snow logs` command for retrieving and streaming logs from the server.
2425

2526
## Fixes and improvements
2627

src/snowflake/cli/_app/commands_registration/builtin_plugins.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from snowflake.cli._plugins.git import plugin_spec as git_plugin_spec
1919
from snowflake.cli._plugins.helpers import plugin_spec as migrate_plugin_spec
2020
from snowflake.cli._plugins.init import plugin_spec as init_plugin_spec
21+
from snowflake.cli._plugins.logs import plugin_spec as logs_plugin_spec
2122
from snowflake.cli._plugins.nativeapp import plugin_spec as nativeapp_plugin_spec
2223
from snowflake.cli._plugins.notebook import plugin_spec as notebook_plugin_spec
2324
from snowflake.cli._plugins.object import plugin_spec as object_plugin_spec
@@ -51,6 +52,7 @@ def get_builtin_plugin_name_to_plugin_spec():
5152
"init": init_plugin_spec,
5253
"workspace": workspace_plugin_spec,
5354
"plugin": plugin_plugin_spec,
55+
"logs": logs_plugin_spec,
5456
}
5557

5658
return plugin_specs

src/snowflake/cli/_plugins/logs/__init__.py

Whitespace-only changes.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import itertools
2+
from datetime import datetime
3+
from typing import Generator, Iterable, Optional, cast
4+
5+
import typer
6+
from click import ClickException
7+
from snowflake.cli._plugins.logs.manager import LogsManager, LogsQueryRow
8+
from snowflake.cli._plugins.object.commands import NameArgument, ObjectArgument
9+
from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
10+
from snowflake.cli.api.identifiers import FQN
11+
from snowflake.cli.api.output.types import (
12+
CommandResult,
13+
MessageResult,
14+
StreamResult,
15+
)
16+
17+
app = SnowTyperFactory()
18+
19+
20+
@app.command(name="logs", requires_connection=True)
21+
def get_logs(
22+
object_type: str = ObjectArgument,
23+
object_name: FQN = NameArgument,
24+
from_: Optional[str] = typer.Option(
25+
None,
26+
"--from",
27+
help="The start time of the logs to retrieve. Accepts all ISO8061 formats",
28+
),
29+
to: Optional[str] = typer.Option(
30+
None,
31+
"--to",
32+
help="The end time of the logs to retrieve. Accepts all ISO8061 formats",
33+
),
34+
refresh_time: int = typer.Option(
35+
None,
36+
"--refresh",
37+
help="If set, the logs will be streamed with the given refresh time in seconds",
38+
),
39+
**options,
40+
):
41+
"""
42+
Retrieves logs for a given object.
43+
"""
44+
if refresh_time and to:
45+
raise ClickException(
46+
"You cannot set both --refresh and --to parameters. Please check the values"
47+
)
48+
49+
from_time = get_datetime_from_string(from_, "--from") if from_ else None
50+
to_time = get_datetime_from_string(to, "--to") if to else None
51+
52+
if refresh_time:
53+
logs_stream: Iterable[LogsQueryRow] = LogsManager().stream_logs(
54+
object_type=object_type,
55+
object_name=object_name,
56+
from_time=from_time,
57+
refresh_time=refresh_time,
58+
)
59+
logs = itertools.chain(
60+
(MessageResult(log.log_message) for logs in logs_stream for log in logs)
61+
)
62+
else:
63+
logs_iterable: Iterable[LogsQueryRow] = LogsManager().get_logs(
64+
object_type=object_type,
65+
object_name=object_name,
66+
from_time=from_time,
67+
to_time=to_time,
68+
)
69+
logs = (MessageResult(log.log_message) for log in logs_iterable) # type: ignore
70+
71+
return StreamResult(cast(Generator[CommandResult, None, None], logs))
72+
73+
74+
def get_datetime_from_string(
75+
date_str: str,
76+
name: Optional[str] = None,
77+
) -> datetime:
78+
try:
79+
return datetime.fromisoformat(date_str)
80+
except ValueError:
81+
raise ClickException(
82+
f"Incorrect format for '{name}'. Please use one of approved ISO formats."
83+
)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import functools
2+
import time
3+
from datetime import datetime
4+
from textwrap import dedent
5+
from typing import Iterable, List, NamedTuple, Optional, Tuple
6+
7+
from click import ClickException
8+
from snowflake.cli._plugins.object.commands import NameArgument, ObjectArgument
9+
from snowflake.cli.api.identifiers import FQN
10+
from snowflake.cli.api.sql_execution import SqlExecutionMixin
11+
from snowflake.connector.cursor import SnowflakeCursor
12+
13+
LogsTableQueryResult = NamedTuple(
14+
"LogsTableQueryResult",
15+
[
16+
("key", str),
17+
("table_name", str),
18+
("default", str),
19+
("level", str),
20+
("description", str),
21+
("type", str),
22+
],
23+
)
24+
25+
LogsQueryRow = NamedTuple(
26+
"LogsQueryRow",
27+
[
28+
("timestamp", datetime),
29+
("database_name", str),
30+
("schema_name", str),
31+
("object_name", str),
32+
("log_level", str),
33+
("log_message", str),
34+
],
35+
)
36+
37+
38+
class LogsManager(SqlExecutionMixin):
39+
def stream_logs(
40+
self,
41+
refresh_time: int,
42+
object_type: str = ObjectArgument,
43+
object_name: FQN = NameArgument,
44+
from_time: Optional[datetime] = None,
45+
) -> Iterable[List[LogsQueryRow]]:
46+
try:
47+
previous_end = from_time
48+
49+
while True:
50+
raw_logs = self.get_raw_logs(
51+
object_type, object_name, previous_end, None
52+
).fetchall()
53+
54+
if raw_logs:
55+
result = self.sanitize_logs(raw_logs)
56+
yield result
57+
if result:
58+
previous_end = result[-1].timestamp
59+
time.sleep(refresh_time)
60+
61+
except KeyboardInterrupt:
62+
return
63+
64+
def get_logs(
65+
self,
66+
object_type: str = ObjectArgument,
67+
object_name: FQN = NameArgument,
68+
from_time: Optional[datetime] = None,
69+
to_time: Optional[datetime] = None,
70+
) -> Iterable[LogsQueryRow]:
71+
"""
72+
Basic function to get a single batch of logs from the server
73+
"""
74+
75+
logs = self.get_raw_logs(object_type, object_name, from_time, to_time)
76+
77+
return self.sanitize_logs(logs)
78+
79+
def get_raw_logs(
80+
self,
81+
object_type: str = ObjectArgument,
82+
object_name: FQN = NameArgument,
83+
from_time: Optional[datetime] = None,
84+
to_time: Optional[datetime] = None,
85+
) -> SnowflakeCursor:
86+
query = dedent(
87+
f"""
88+
SELECT
89+
timestamp,
90+
resource_attributes:"snow.database.name"::string as database_name,
91+
resource_attributes:"snow.schema.name"::string as schema_name,
92+
resource_attributes:"snow.{object_type}.name"::string as object_name,
93+
record:severity_text::string as log_level,
94+
value::string as log_message
95+
FROM {self.logs_table}
96+
WHERE record_type = 'LOG'
97+
AND (record:severity_text = 'INFO' or record:severity_text is NULL )
98+
AND object_name = '{object_name}'
99+
{self._get_timestamp_query(from_time, to_time)}
100+
ORDER BY timestamp;
101+
"""
102+
).strip()
103+
104+
result = self.execute_query(query)
105+
106+
return result
107+
108+
@functools.cached_property
109+
def logs_table(self) -> str:
110+
"""
111+
Get the table where logs are."""
112+
query_result = self.execute_query(
113+
f"SHOW PARAMETERS LIKE 'event_table' IN ACCOUNT;"
114+
).fetchone()
115+
116+
try:
117+
logs_table_query_result = LogsTableQueryResult(*query_result)
118+
except TypeError:
119+
raise ClickException(
120+
"Encountered error while querying for logs table. Please check if your account has an event_table"
121+
)
122+
return logs_table_query_result.table_name
123+
124+
def _get_timestamp_query(
125+
self, from_time: Optional[datetime], to_time: Optional[datetime]
126+
):
127+
if from_time and to_time and from_time > to_time:
128+
raise ClickException(
129+
"From_time cannot be later than to_time. Please check the values"
130+
)
131+
query = []
132+
133+
if from_time is not None:
134+
query.append(
135+
f"AND timestamp >= TO_TIMESTAMP_LTZ('{from_time.isoformat()}')\n"
136+
)
137+
138+
if to_time is not None:
139+
query.append(
140+
f"AND timestamp <= TO_TIMESTAMP_LTZ('{to_time.isoformat()}')\n"
141+
)
142+
143+
return "".join(query)
144+
145+
def sanitize_logs(self, logs: SnowflakeCursor | List[Tuple]) -> List[LogsQueryRow]:
146+
try:
147+
return [LogsQueryRow(*log) for log in logs]
148+
except TypeError:
149+
raise ClickException(
150+
"Logs table has incorrect format. Please check the logs_table in your database"
151+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from snowflake.cli._plugins.logs import commands
2+
from snowflake.cli.api.plugins.command import (
3+
SNOWCLI_ROOT_COMMAND_PATH,
4+
CommandSpec,
5+
CommandType,
6+
plugin_hook_impl,
7+
)
8+
9+
10+
@plugin_hook_impl
11+
def command_spec():
12+
return CommandSpec(
13+
parent_command_path=SNOWCLI_ROOT_COMMAND_PATH,
14+
command_type=CommandType.SINGLE_COMMAND,
15+
typer_instance=commands.app.create_instance(),
16+
)

src/snowflake/cli/api/sql_execution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def execute_string(self, query: str, **kwargs) -> Iterable[SnowflakeCursor]:
8989
"""Executes a single SQL query and returns the results"""
9090
return self._execute_string(query, **kwargs)
9191

92-
def execute_query(self, query: str, **kwargs):
92+
def execute_query(self, query: str, **kwargs) -> SnowflakeCursor:
9393
"""Executes a single SQL query and returns the last result"""
9494
*_, last_result = list(self.execute_string(dedent(query), **kwargs))
9595
return last_result

0 commit comments

Comments
 (0)