dbt-logger 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbt_logger-0.1.1/LICENSE +21 -0
- dbt_logger-0.1.1/PKG-INFO +161 -0
- dbt_logger-0.1.1/README.md +137 -0
- dbt_logger-0.1.1/dbt_logger/__init__.py +7 -0
- dbt_logger-0.1.1/dbt_logger/__main__.py +6 -0
- dbt_logger-0.1.1/dbt_logger/cli.py +159 -0
- dbt_logger-0.1.1/dbt_logger/logger.py +161 -0
- dbt_logger-0.1.1/dbt_logger/utils.py +234 -0
- dbt_logger-0.1.1/pyproject.toml +41 -0
dbt_logger-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tristan Schwartz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: dbt-logger
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A lightweight logging utility for dbt projects
|
|
5
|
+
Home-page: https://github.com/TristanSchwartz/dbt-logger
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dbt,logging,data,analytics
|
|
8
|
+
Author: Tristan Schwartz
|
|
9
|
+
Requires-Python: >=3.9.2,<3.13
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Requires-Dist: cryptography (>=41.0.0,<42.0.0)
|
|
20
|
+
Requires-Dist: pyyaml (>=6.0.0,<7.0.0)
|
|
21
|
+
Requires-Dist: snowflake-connector-python (>=3.0.0,<4.0.0)
|
|
22
|
+
Project-URL: Repository, https://github.com/TristanSchwartz/dbt-logger
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# dbt-logger
|
|
26
|
+
|
|
27
|
+
A logging utility that captures dbt command output and stores it in Snowflake for auditing and monitoring. **Uses your existing dbt credentials - no additional configuration needed!**
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install dbt-logger
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
The simplest way - uses your existing dbt profiles.yml:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from dbt_logger import DbtLogger
|
|
41
|
+
|
|
42
|
+
# Automatically uses credentials from dbt profiles.yml
|
|
43
|
+
logger = DbtLogger(repo_name="my_analytics_project")
|
|
44
|
+
|
|
45
|
+
# Run dbt commands and log them
|
|
46
|
+
logger.run_command("dbt build")
|
|
47
|
+
logger.run_command("dbt run --select +sales_model")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
That's it! Logs are automatically saved to `[your_dbt_database].logging.dbt_logging`
|
|
51
|
+
|
|
52
|
+
## Configuration File (Optional)
|
|
53
|
+
|
|
54
|
+
Create `dbt/dbt_logger.yml` in your dbt project to configure logging destination:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
# dbt/dbt_logger.yml
|
|
58
|
+
|
|
59
|
+
# Option 1: Custom schema in dbt database
|
|
60
|
+
schema: audit_logs
|
|
61
|
+
|
|
62
|
+
# Option 2: Completely separate database for logs
|
|
63
|
+
database: ADMIN_DB
|
|
64
|
+
schema: LOGGING
|
|
65
|
+
table_name: dbt_execution_logs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Precedence order:**
|
|
69
|
+
1. Parameters passed to `DbtLogger()` (highest)
|
|
70
|
+
2. Settings in `dbt/dbt_logger.yml`
|
|
71
|
+
3. Defaults: `{dbt_database}.logging.dbt_logging` (lowest)
|
|
72
|
+
|
|
73
|
+
## Configuration Options
|
|
74
|
+
|
|
75
|
+
### Use Specific dbt Profile/Target
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# Use a specific profile and target from profiles.yml
|
|
79
|
+
logger = DbtLogger(
|
|
80
|
+
repo_name="my_project",
|
|
81
|
+
profile_name="my_profile", # Optional: defaults to first profile
|
|
82
|
+
target="prod" # Optional: defaults to profile's default target
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Override Logging Location in Code
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# Override config file settings
|
|
90
|
+
logger = DbtLogger(
|
|
91
|
+
repo_name="my_project",
|
|
92
|
+
database="ADMIN_DB",
|
|
93
|
+
schema="LOGGING",
|
|
94
|
+
table_name="my_custom_logs"
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Manual Connection Parameters (Advanced)
|
|
99
|
+
|
|
100
|
+
If you need to use different credentials than dbt:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from dbt_logger import DbtLogger, get_connection_params_from_env
|
|
104
|
+
|
|
105
|
+
# Option 1: From environment variables
|
|
106
|
+
connection_params = get_connection_params_from_env()
|
|
107
|
+
|
|
108
|
+
# Option 2: Manual dict
|
|
109
|
+
connection_params = {
|
|
110
|
+
'account': 'your_account',
|
|
111
|
+
'user': 'your_user',
|
|
112
|
+
'password': 'your_password',
|
|
113
|
+
'database': 'your_database',
|
|
114
|
+
'warehouse': 'your_warehouse',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logger = DbtLogger(
|
|
118
|
+
repo_name="my_project",
|
|
119
|
+
connection_params=connection_params
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## How It Works
|
|
124
|
+
|
|
125
|
+
1. **Reads your dbt profiles.yml** - Automatically finds and parses your dbt configuration
|
|
126
|
+
2. **Supports private key auth** - Works with your existing DBT_USER, DBT_PVK_PATH, DBT_PVK_PASS env vars
|
|
127
|
+
3. **Creates logging infrastructure** - Auto-creates schema and table on first run
|
|
128
|
+
4. **Captures everything** - Runs dbt commands and logs all output in real-time
|
|
129
|
+
5. **Stores in Snowflake** - Saves command history for auditing and monitoring
|
|
130
|
+
|
|
131
|
+
## Logging Table Schema
|
|
132
|
+
|
|
133
|
+
```sql
|
|
134
|
+
-- Default location: [your_dbt_database].logging.dbt_logging
|
|
135
|
+
CREATE TABLE dbt_logging (
|
|
136
|
+
repo_name STRING, -- Your dbt project/repo name
|
|
137
|
+
command STRING, -- The dbt command executed
|
|
138
|
+
stdout_log STRING, -- Complete command output
|
|
139
|
+
log_time TIMESTAMP_LTZ -- Execution timestamp
|
|
140
|
+
);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## dbt profiles.yml Locations
|
|
144
|
+
|
|
145
|
+
The logger checks for profiles.yml in this order:
|
|
146
|
+
1. `$DBT_PROFILES_DIR/profiles.yml`
|
|
147
|
+
2. `./dbt/profiles/profiles.yml` (current directory)
|
|
148
|
+
3. `~/.dbt/profiles.yml` (default dbt location)
|
|
149
|
+
|
|
150
|
+
## Features
|
|
151
|
+
|
|
152
|
+
- ✅ **Zero Config** - Uses your existing dbt credentials
|
|
153
|
+
- ✅ **Private Key Auth** - Full support for private key authentication
|
|
154
|
+
- ✅ **Auto Setup** - Creates schema and table automatically
|
|
155
|
+
- ✅ **Real-time Output** - See command output live while logging
|
|
156
|
+
- ✅ **Flexible Storage** - Default to dbt database or use separate logging database
|
|
157
|
+
- ✅ **Environment Variables** - Resolves Jinja env_var() expressions from profiles.yml
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# dbt-logger
|
|
2
|
+
|
|
3
|
+
A logging utility that captures dbt command output and stores it in Snowflake for auditing and monitoring. **Uses your existing dbt credentials - no additional configuration needed!**
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install dbt-logger
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
The simplest way - uses your existing dbt profiles.yml:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from dbt_logger import DbtLogger
|
|
17
|
+
|
|
18
|
+
# Automatically uses credentials from dbt profiles.yml
|
|
19
|
+
logger = DbtLogger(repo_name="my_analytics_project")
|
|
20
|
+
|
|
21
|
+
# Run dbt commands and log them
|
|
22
|
+
logger.run_command("dbt build")
|
|
23
|
+
logger.run_command("dbt run --select +sales_model")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That's it! Logs are automatically saved to `[your_dbt_database].logging.dbt_logging`
|
|
27
|
+
|
|
28
|
+
## Configuration File (Optional)
|
|
29
|
+
|
|
30
|
+
Create `dbt/dbt_logger.yml` in your dbt project to configure logging destination:
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
# dbt/dbt_logger.yml
|
|
34
|
+
|
|
35
|
+
# Option 1: Custom schema in dbt database
|
|
36
|
+
schema: audit_logs
|
|
37
|
+
|
|
38
|
+
# Option 2: Completely separate database for logs
|
|
39
|
+
database: ADMIN_DB
|
|
40
|
+
schema: LOGGING
|
|
41
|
+
table_name: dbt_execution_logs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Precedence order:**
|
|
45
|
+
1. Parameters passed to `DbtLogger()` (highest)
|
|
46
|
+
2. Settings in `dbt/dbt_logger.yml`
|
|
47
|
+
3. Defaults: `{dbt_database}.logging.dbt_logging` (lowest)
|
|
48
|
+
|
|
49
|
+
## Configuration Options
|
|
50
|
+
|
|
51
|
+
### Use Specific dbt Profile/Target
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# Use a specific profile and target from profiles.yml
|
|
55
|
+
logger = DbtLogger(
|
|
56
|
+
repo_name="my_project",
|
|
57
|
+
profile_name="my_profile", # Optional: defaults to first profile
|
|
58
|
+
target="prod" # Optional: defaults to profile's default target
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Override Logging Location in Code
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# Override config file settings
|
|
66
|
+
logger = DbtLogger(
|
|
67
|
+
repo_name="my_project",
|
|
68
|
+
database="ADMIN_DB",
|
|
69
|
+
schema="LOGGING",
|
|
70
|
+
table_name="my_custom_logs"
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Manual Connection Parameters (Advanced)
|
|
75
|
+
|
|
76
|
+
If you need to use different credentials than dbt:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from dbt_logger import DbtLogger, get_connection_params_from_env
|
|
80
|
+
|
|
81
|
+
# Option 1: From environment variables
|
|
82
|
+
connection_params = get_connection_params_from_env()
|
|
83
|
+
|
|
84
|
+
# Option 2: Manual dict
|
|
85
|
+
connection_params = {
|
|
86
|
+
'account': 'your_account',
|
|
87
|
+
'user': 'your_user',
|
|
88
|
+
'password': 'your_password',
|
|
89
|
+
'database': 'your_database',
|
|
90
|
+
'warehouse': 'your_warehouse',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
logger = DbtLogger(
|
|
94
|
+
repo_name="my_project",
|
|
95
|
+
connection_params=connection_params
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## How It Works
|
|
100
|
+
|
|
101
|
+
1. **Reads your dbt profiles.yml** - Automatically finds and parses your dbt configuration
|
|
102
|
+
2. **Supports private key auth** - Works with your existing DBT_USER, DBT_PVK_PATH, DBT_PVK_PASS env vars
|
|
103
|
+
3. **Creates logging infrastructure** - Auto-creates schema and table on first run
|
|
104
|
+
4. **Captures everything** - Runs dbt commands and logs all output in real-time
|
|
105
|
+
5. **Stores in Snowflake** - Saves command history for auditing and monitoring
|
|
106
|
+
|
|
107
|
+
## Logging Table Schema
|
|
108
|
+
|
|
109
|
+
```sql
|
|
110
|
+
-- Default location: [your_dbt_database].logging.dbt_logging
|
|
111
|
+
CREATE TABLE dbt_logging (
|
|
112
|
+
repo_name STRING, -- Your dbt project/repo name
|
|
113
|
+
command STRING, -- The dbt command executed
|
|
114
|
+
stdout_log STRING, -- Complete command output
|
|
115
|
+
log_time TIMESTAMP_LTZ -- Execution timestamp
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## dbt profiles.yml Locations
|
|
120
|
+
|
|
121
|
+
The logger checks for profiles.yml in this order:
|
|
122
|
+
1. `$DBT_PROFILES_DIR/profiles.yml`
|
|
123
|
+
2. `./dbt/profiles/profiles.yml` (current directory)
|
|
124
|
+
3. `~/.dbt/profiles.yml` (default dbt location)
|
|
125
|
+
|
|
126
|
+
## Features
|
|
127
|
+
|
|
128
|
+
- ✅ **Zero Config** - Uses your existing dbt credentials
|
|
129
|
+
- ✅ **Private Key Auth** - Full support for private key authentication
|
|
130
|
+
- ✅ **Auto Setup** - Creates schema and table automatically
|
|
131
|
+
- ✅ **Real-time Output** - See command output live while logging
|
|
132
|
+
- ✅ **Flexible Storage** - Default to dbt database or use separate logging database
|
|
133
|
+
- ✅ **Environment Variables** - Resolves Jinja env_var() expressions from profiles.yml
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""dbt-logger: A logger for dbt projects"""
|
|
2
|
+
|
|
3
|
+
from .logger import DbtLogger
|
|
4
|
+
from .utils import get_connection_params_from_dbt, get_connection_params_from_env
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["DbtLogger", "get_connection_params_from_dbt", "get_connection_params_from_env"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""CLI interface for dbt-logger."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from .utils import get_connection_params_from_dbt, get_logger_config, get_dbt_profile_path
|
|
7
|
+
from .logger import DbtLogger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def show_config(repo_name: str, target: Optional[str] = None):
|
|
11
|
+
"""Show the current configuration and where logs will be written."""
|
|
12
|
+
print("=" * 60)
|
|
13
|
+
print("dbt-logger Configuration")
|
|
14
|
+
print("=" * 60)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
# Find profiles.yml
|
|
18
|
+
profiles_path = get_dbt_profile_path()
|
|
19
|
+
print(f"✓ profiles.yml: {profiles_path}")
|
|
20
|
+
|
|
21
|
+
# Get connection params
|
|
22
|
+
connection_params = get_connection_params_from_dbt(target=target)
|
|
23
|
+
dbt_database = connection_params.get('database')
|
|
24
|
+
print(f"✓ dbt database: {dbt_database}")
|
|
25
|
+
print(f"✓ target: {target or 'default'}")
|
|
26
|
+
|
|
27
|
+
# Get logger config
|
|
28
|
+
config = get_logger_config()
|
|
29
|
+
if config:
|
|
30
|
+
print(f"✓ Config file found with: {config}")
|
|
31
|
+
else:
|
|
32
|
+
print(" No dbt_logger.yml config file found (using defaults)")
|
|
33
|
+
|
|
34
|
+
# Determine final logging location
|
|
35
|
+
database = config.get('database') or dbt_database
|
|
36
|
+
schema = config.get('schema') or "logging"
|
|
37
|
+
table_name = config.get('table_name') or "dbt_logging"
|
|
38
|
+
|
|
39
|
+
print("\n" + "=" * 60)
|
|
40
|
+
print("Logs will be written to:")
|
|
41
|
+
print("=" * 60)
|
|
42
|
+
print(f" Database: {database}")
|
|
43
|
+
print(f" Schema: {schema}")
|
|
44
|
+
print(f" Table: {table_name}")
|
|
45
|
+
print(f" Full path: {database}.{schema}.{table_name}")
|
|
46
|
+
print("=" * 60)
|
|
47
|
+
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_connection(repo_name: str, target: Optional[str] = None):
|
|
54
|
+
"""Test the Snowflake connection."""
|
|
55
|
+
print("=" * 60)
|
|
56
|
+
print("Testing Snowflake Connection")
|
|
57
|
+
print("=" * 60)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
connection_params = get_connection_params_from_dbt(target=target)
|
|
61
|
+
print(f"✓ Loaded credentials for target: {target or 'default'}")
|
|
62
|
+
|
|
63
|
+
# Try to create logger (this will test connection)
|
|
64
|
+
logger = DbtLogger(repo_name=repo_name, target=target)
|
|
65
|
+
print(f"✓ Connected to Snowflake")
|
|
66
|
+
print(f"✓ Verified/created: {logger.full_table_name}")
|
|
67
|
+
print("\n" + "=" * 60)
|
|
68
|
+
print("Connection test PASSED ✓")
|
|
69
|
+
print("=" * 60)
|
|
70
|
+
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"\n✗ Connection test FAILED", file=sys.stderr)
|
|
73
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
74
|
+
print("=" * 60)
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_test_command(repo_name: str, target: Optional[str] = None):
|
|
79
|
+
"""Run a test dbt command and log it."""
|
|
80
|
+
print("=" * 60)
|
|
81
|
+
print("Running Test Command")
|
|
82
|
+
print("=" * 60)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
logger = DbtLogger(repo_name=repo_name, target=target)
|
|
86
|
+
print(f"Logging to: {logger.full_table_name}\n")
|
|
87
|
+
|
|
88
|
+
# Run a simple dbt command
|
|
89
|
+
exit_code = logger.run_command("dbt --version")
|
|
90
|
+
|
|
91
|
+
if exit_code == 0:
|
|
92
|
+
print("\n" + "=" * 60)
|
|
93
|
+
print("Test PASSED ✓")
|
|
94
|
+
print(f"Check {logger.full_table_name} for the logged output")
|
|
95
|
+
print("=" * 60)
|
|
96
|
+
else:
|
|
97
|
+
print(f"\nCommand exited with code: {exit_code}")
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
"""Main CLI entry point."""
|
|
106
|
+
parser = argparse.ArgumentParser(
|
|
107
|
+
prog='dbt-logger',
|
|
108
|
+
description='Test and configure dbt-logger'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
'--show-config',
|
|
113
|
+
action='store_true',
|
|
114
|
+
help='Show where logs will be written'
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
'--test-connection',
|
|
119
|
+
action='store_true',
|
|
120
|
+
help='Test the Snowflake connection'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
'--test-run',
|
|
125
|
+
action='store_true',
|
|
126
|
+
help='Run a test command (dbt --version) and log it'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
'--repo-name',
|
|
131
|
+
default='test_repo',
|
|
132
|
+
help='Repository name for testing (default: test_repo)'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
'--target',
|
|
137
|
+
help='dbt target to use (e.g., dev, prod)'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
args = parser.parse_args()
|
|
141
|
+
|
|
142
|
+
# If no action specified, show help
|
|
143
|
+
if not any([args.show_config, args.test_connection, args.test_run]):
|
|
144
|
+
parser.print_help()
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
|
|
147
|
+
# Run requested actions
|
|
148
|
+
if args.show_config:
|
|
149
|
+
show_config(args.repo_name, args.target)
|
|
150
|
+
|
|
151
|
+
if args.test_connection:
|
|
152
|
+
test_connection(args.repo_name, args.target)
|
|
153
|
+
|
|
154
|
+
if args.test_run:
|
|
155
|
+
run_test_command(args.repo_name, args.target)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == '__main__':
|
|
159
|
+
main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Main logger implementation for dbt projects."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
import snowflake.connector
|
|
8
|
+
from .utils import get_connection_params_from_dbt, get_logger_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DbtLogger:
|
|
12
|
+
"""A logger that captures dbt command output and logs to Snowflake."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
repo_name: str,
|
|
17
|
+
connection_params: Optional[Dict[str, Any]] = None,
|
|
18
|
+
profile_name: Optional[str] = None,
|
|
19
|
+
target: Optional[str] = None,
|
|
20
|
+
table_name: Optional[str] = None,
|
|
21
|
+
schema: Optional[str] = None,
|
|
22
|
+
database: Optional[str] = None
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the DbtLogger.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
repo_name: Name of the dbt project/repo
|
|
29
|
+
connection_params: Snowflake connection parameters (if None, reads from dbt profiles.yml)
|
|
30
|
+
profile_name: dbt profile name (only used if connection_params is None)
|
|
31
|
+
target: dbt target name like 'dev' or 'prod' (only used if connection_params is None)
|
|
32
|
+
table_name: Name of the logging table (overrides config file)
|
|
33
|
+
schema: Schema name (overrides config file)
|
|
34
|
+
database: Database name (overrides config file)
|
|
35
|
+
"""
|
|
36
|
+
self.repo_name = repo_name
|
|
37
|
+
|
|
38
|
+
# Get connection params from dbt profiles if not provided
|
|
39
|
+
if connection_params is None:
|
|
40
|
+
connection_params = get_connection_params_from_dbt(
|
|
41
|
+
profile_name=profile_name,
|
|
42
|
+
target=target
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self.connection_params = connection_params
|
|
46
|
+
|
|
47
|
+
# Load config file settings
|
|
48
|
+
config = get_logger_config()
|
|
49
|
+
|
|
50
|
+
# Determine database, schema, and table name with precedence:
|
|
51
|
+
# 1. Explicit parameter
|
|
52
|
+
# 2. Config file
|
|
53
|
+
# 3. Defaults
|
|
54
|
+
dbt_database = connection_params.get('database')
|
|
55
|
+
|
|
56
|
+
self.database = database or config.get('database') or dbt_database
|
|
57
|
+
self.schema = schema or config.get('schema') or "logging"
|
|
58
|
+
self.table_name = table_name or config.get('table_name') or "dbt_logging"
|
|
59
|
+
|
|
60
|
+
self.full_table_name = f"{self.database}.{self.schema}.{self.table_name}"
|
|
61
|
+
|
|
62
|
+
# Ensure the logging table exists
|
|
63
|
+
self._ensure_table_exists()
|
|
64
|
+
|
|
65
|
+
def _get_connection(self):
|
|
66
|
+
"""Get a Snowflake connection."""
|
|
67
|
+
return snowflake.connector.connect(**self.connection_params)
|
|
68
|
+
|
|
69
|
+
def _ensure_table_exists(self):
|
|
70
|
+
"""Create the logging schema and table if they don't exist."""
|
|
71
|
+
create_schema_sql = f"CREATE SCHEMA IF NOT EXISTS {self.database}.{self.schema}"
|
|
72
|
+
|
|
73
|
+
create_table_sql = f"""
|
|
74
|
+
CREATE TABLE IF NOT EXISTS {self.full_table_name} (
|
|
75
|
+
repo_name STRING,
|
|
76
|
+
command STRING,
|
|
77
|
+
stdout_log STRING,
|
|
78
|
+
log_time TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP()
|
|
79
|
+
)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
conn = self._get_connection()
|
|
84
|
+
cursor = conn.cursor()
|
|
85
|
+
cursor.execute(create_schema_sql)
|
|
86
|
+
cursor.execute(create_table_sql)
|
|
87
|
+
cursor.close()
|
|
88
|
+
conn.close()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"Warning: Could not create logging table: {e}")
|
|
91
|
+
|
|
92
|
+
def run_command(self, command: str) -> int:
|
|
93
|
+
"""
|
|
94
|
+
Run a dbt command and log its output to Snowflake.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
command: The full dbt command to run (e.g., "dbt build", "dbt run --select model_name")
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Exit code of the command
|
|
101
|
+
"""
|
|
102
|
+
print(f"Running: {command}")
|
|
103
|
+
print("=" * 60)
|
|
104
|
+
|
|
105
|
+
# Run the command and capture output
|
|
106
|
+
try:
|
|
107
|
+
process = subprocess.Popen(
|
|
108
|
+
command,
|
|
109
|
+
shell=True,
|
|
110
|
+
stdout=subprocess.PIPE,
|
|
111
|
+
stderr=subprocess.STDOUT,
|
|
112
|
+
text=True,
|
|
113
|
+
bufsize=1,
|
|
114
|
+
universal_newlines=True
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
output_lines = []
|
|
118
|
+
for line in process.stdout:
|
|
119
|
+
# Print in real-time
|
|
120
|
+
print(line, end='')
|
|
121
|
+
output_lines.append(line)
|
|
122
|
+
|
|
123
|
+
process.wait()
|
|
124
|
+
exit_code = process.returncode
|
|
125
|
+
|
|
126
|
+
# Combine all output
|
|
127
|
+
full_output = ''.join(output_lines)
|
|
128
|
+
|
|
129
|
+
# Log to Snowflake
|
|
130
|
+
self._log_to_snowflake(command, full_output)
|
|
131
|
+
|
|
132
|
+
print("=" * 60)
|
|
133
|
+
print(f"Command completed with exit code: {exit_code}")
|
|
134
|
+
|
|
135
|
+
return exit_code
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
error_msg = f"Error running command: {e}"
|
|
139
|
+
print(error_msg)
|
|
140
|
+
self._log_to_snowflake(command, error_msg)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
def _log_to_snowflake(self, command: str, stdout_log: str):
|
|
144
|
+
"""Insert the command and output into Snowflake."""
|
|
145
|
+
insert_sql = f"""
|
|
146
|
+
INSERT INTO {self.full_table_name} (repo_name, command, stdout_log, log_time)
|
|
147
|
+
VALUES (%s, %s, %s, %s)
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
conn = self._get_connection()
|
|
152
|
+
cursor = conn.cursor()
|
|
153
|
+
cursor.execute(
|
|
154
|
+
insert_sql,
|
|
155
|
+
(self.repo_name, command, stdout_log, datetime.now())
|
|
156
|
+
)
|
|
157
|
+
conn.commit()
|
|
158
|
+
cursor.close()
|
|
159
|
+
conn.close()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
print(f"Warning: Could not log to Snowflake: {e}")
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Utility functions for dbt-logger."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
import os
|
|
5
|
+
import yaml
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_logger_config() -> Dict[str, Any]:
|
|
11
|
+
"""
|
|
12
|
+
Load configuration from dbt/dbt_logger.yml if it exists.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Dictionary with optional keys: database, schema, table_name
|
|
16
|
+
"""
|
|
17
|
+
config_paths = [
|
|
18
|
+
Path.cwd() / 'dbt' / 'dbt_logger.yml',
|
|
19
|
+
Path.cwd() / 'dbt_logger.yml',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Also check next to profiles.yml if found
|
|
23
|
+
try:
|
|
24
|
+
from .utils import get_dbt_profile_path
|
|
25
|
+
profiles_path = get_dbt_profile_path()
|
|
26
|
+
profiles_dir = profiles_path.parent.parent if profiles_path.name == 'profiles.yml' and profiles_path.parent.name == 'profiles' else profiles_path.parent
|
|
27
|
+
config_paths.append(profiles_dir / 'dbt_logger.yml')
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
for config_path in config_paths:
|
|
32
|
+
if config_path.exists():
|
|
33
|
+
with open(config_path, 'r') as f:
|
|
34
|
+
config = yaml.safe_load(f) or {}
|
|
35
|
+
return config
|
|
36
|
+
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_env_var(value: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Resolve Jinja-style environment variable references.
|
|
43
|
+
|
|
44
|
+
Handles patterns like {{ env_var('VAR_NAME') }}
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(value, str):
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
# Match {{ env_var('VAR_NAME') }} or {{ env_var("VAR_NAME") }}
|
|
50
|
+
pattern = r"{{\s*env_var\(['\"]([^'\"]+)['\"]\)\s*}}"
|
|
51
|
+
|
|
52
|
+
def replacer(match):
|
|
53
|
+
env_var_name = match.group(1)
|
|
54
|
+
env_value = os.getenv(env_var_name)
|
|
55
|
+
if env_value is None:
|
|
56
|
+
raise ValueError(f"Environment variable '{env_var_name}' is not set")
|
|
57
|
+
return env_value
|
|
58
|
+
|
|
59
|
+
return re.sub(pattern, replacer, value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_dbt_profile_path() -> Path:
|
|
63
|
+
"""
|
|
64
|
+
Get the path to the dbt profiles.yml file.
|
|
65
|
+
|
|
66
|
+
Checks in order:
|
|
67
|
+
1. DBT_PROFILES_DIR environment variable
|
|
68
|
+
2. Current directory ./dbt/profiles/profiles.yml
|
|
69
|
+
3. ~/.dbt/profiles.yml (default location)
|
|
70
|
+
"""
|
|
71
|
+
# Check environment variable
|
|
72
|
+
profiles_dir = os.getenv('DBT_PROFILES_DIR')
|
|
73
|
+
if profiles_dir:
|
|
74
|
+
profiles_path = Path(profiles_dir) / 'profiles.yml'
|
|
75
|
+
if profiles_path.exists():
|
|
76
|
+
return profiles_path
|
|
77
|
+
|
|
78
|
+
# Check local dbt/profiles directory
|
|
79
|
+
local_path = Path.cwd() / 'dbt' / 'profiles' / 'profiles.yml'
|
|
80
|
+
if local_path.exists():
|
|
81
|
+
return local_path
|
|
82
|
+
|
|
83
|
+
# Check default home directory
|
|
84
|
+
home_path = Path.home() / '.dbt' / 'profiles.yml'
|
|
85
|
+
if home_path.exists():
|
|
86
|
+
return home_path
|
|
87
|
+
|
|
88
|
+
raise FileNotFoundError(
|
|
89
|
+
"Could not find profiles.yml. Checked:\n"
|
|
90
|
+
f" - $DBT_PROFILES_DIR/profiles.yml\n"
|
|
91
|
+
f" - {local_path}\n"
|
|
92
|
+
f" - {home_path}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_connection_params_from_dbt(
|
|
97
|
+
profile_name: Optional[str] = None,
|
|
98
|
+
target: Optional[str] = None
|
|
99
|
+
) -> Dict[str, Any]:
|
|
100
|
+
"""
|
|
101
|
+
Get Snowflake connection parameters from dbt profiles.yml.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
profile_name: Name of the profile in profiles.yml (if None, uses DBT_PROFILE env var or first profile)
|
|
105
|
+
target: Target name (dev/prod) (if None, uses profile's default target)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Dictionary of connection parameters suitable for snowflake.connector.connect()
|
|
109
|
+
"""
|
|
110
|
+
profiles_path = get_dbt_profile_path()
|
|
111
|
+
|
|
112
|
+
with open(profiles_path, 'r') as f:
|
|
113
|
+
profiles = yaml.safe_load(f)
|
|
114
|
+
|
|
115
|
+
# Determine which profile to use
|
|
116
|
+
if profile_name is None:
|
|
117
|
+
profile_name = os.getenv('DBT_PROFILE')
|
|
118
|
+
|
|
119
|
+
if profile_name is None:
|
|
120
|
+
# Use the first profile (excluding 'config' key if present)
|
|
121
|
+
available_profiles = [k for k in profiles.keys() if k != 'config']
|
|
122
|
+
if not available_profiles:
|
|
123
|
+
raise ValueError("No profiles found in profiles.yml")
|
|
124
|
+
profile_name = available_profiles[0]
|
|
125
|
+
|
|
126
|
+
if profile_name not in profiles:
|
|
127
|
+
raise ValueError(f"Profile '{profile_name}' not found in profiles.yml")
|
|
128
|
+
|
|
129
|
+
profile = profiles[profile_name]
|
|
130
|
+
|
|
131
|
+
# Determine which target to use
|
|
132
|
+
if target is None:
|
|
133
|
+
target = profile.get('target')
|
|
134
|
+
|
|
135
|
+
if target is None:
|
|
136
|
+
raise ValueError(f"No target specified and profile '{profile_name}' has no default target")
|
|
137
|
+
|
|
138
|
+
if target not in profile.get('outputs', {}):
|
|
139
|
+
raise ValueError(f"Target '{target}' not found in profile '{profile_name}'")
|
|
140
|
+
|
|
141
|
+
output = profile['outputs'][target]
|
|
142
|
+
|
|
143
|
+
# Resolve environment variables in the output
|
|
144
|
+
resolved_output = {}
|
|
145
|
+
for key, value in output.items():
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
resolved_output[key] = _resolve_env_var(value)
|
|
148
|
+
else:
|
|
149
|
+
resolved_output[key] = value
|
|
150
|
+
|
|
151
|
+
# Build connection parameters for Snowflake
|
|
152
|
+
params = {
|
|
153
|
+
'account': resolved_output.get('account'),
|
|
154
|
+
'user': resolved_output.get('user'),
|
|
155
|
+
'warehouse': resolved_output.get('warehouse'),
|
|
156
|
+
'database': resolved_output.get('database'),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Handle authentication - private key or password
|
|
160
|
+
if 'private_key_path' in resolved_output:
|
|
161
|
+
# Private key authentication
|
|
162
|
+
private_key_path = resolved_output['private_key_path']
|
|
163
|
+
private_key_passphrase = resolved_output.get('private_key_passphrase')
|
|
164
|
+
|
|
165
|
+
# Read and parse the private key
|
|
166
|
+
from cryptography.hazmat.backends import default_backend
|
|
167
|
+
from cryptography.hazmat.primitives import serialization
|
|
168
|
+
|
|
169
|
+
with open(private_key_path, 'rb') as key_file:
|
|
170
|
+
private_key_data = key_file.read()
|
|
171
|
+
|
|
172
|
+
passphrase = private_key_passphrase.encode() if private_key_passphrase else None
|
|
173
|
+
|
|
174
|
+
private_key = serialization.load_pem_private_key(
|
|
175
|
+
private_key_data,
|
|
176
|
+
password=passphrase,
|
|
177
|
+
backend=default_backend()
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
pkb = private_key.private_bytes(
|
|
181
|
+
encoding=serialization.Encoding.DER,
|
|
182
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
183
|
+
encryption_algorithm=serialization.NoEncryption()
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
params['private_key'] = pkb
|
|
187
|
+
elif 'password' in resolved_output:
|
|
188
|
+
params['password'] = resolved_output['password']
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError("No authentication method found (private_key_path or password)")
|
|
191
|
+
|
|
192
|
+
# Optional parameters
|
|
193
|
+
if 'role' in resolved_output:
|
|
194
|
+
params['role'] = resolved_output['role']
|
|
195
|
+
|
|
196
|
+
if 'schema' in resolved_output:
|
|
197
|
+
params['schema'] = resolved_output['schema']
|
|
198
|
+
|
|
199
|
+
return params
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_connection_params_from_env() -> Dict[str, Any]:
|
|
203
|
+
"""
|
|
204
|
+
Get Snowflake connection parameters from environment variables.
|
|
205
|
+
|
|
206
|
+
Expected environment variables:
|
|
207
|
+
- SNOWFLAKE_ACCOUNT
|
|
208
|
+
- SNOWFLAKE_USER
|
|
209
|
+
- SNOWFLAKE_PASSWORD
|
|
210
|
+
- SNOWFLAKE_DATABASE
|
|
211
|
+
- SNOWFLAKE_WAREHOUSE
|
|
212
|
+
- SNOWFLAKE_ROLE (optional)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Dictionary of connection parameters
|
|
216
|
+
"""
|
|
217
|
+
params = {
|
|
218
|
+
'account': os.getenv('SNOWFLAKE_ACCOUNT'),
|
|
219
|
+
'user': os.getenv('SNOWFLAKE_USER'),
|
|
220
|
+
'password': os.getenv('SNOWFLAKE_PASSWORD'),
|
|
221
|
+
'database': os.getenv('SNOWFLAKE_DATABASE'),
|
|
222
|
+
'warehouse': os.getenv('SNOWFLAKE_WAREHOUSE'),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
role = os.getenv('SNOWFLAKE_ROLE')
|
|
226
|
+
if role:
|
|
227
|
+
params['role'] = role
|
|
228
|
+
|
|
229
|
+
# Validate required parameters
|
|
230
|
+
missing = [k for k, v in params.items() if v is None and k != 'role']
|
|
231
|
+
if missing:
|
|
232
|
+
raise ValueError(f"Missing required environment variables: {', '.join(f'SNOWFLAKE_{k.upper()}' for k in missing)}")
|
|
233
|
+
|
|
234
|
+
return params
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "dbt-logger"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "A lightweight logging utility for dbt projects"
|
|
5
|
+
authors = ["Tristan Schwartz"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/TristanSchwartz/dbt-logger"
|
|
9
|
+
repository = "https://github.com/TristanSchwartz/dbt-logger"
|
|
10
|
+
keywords = ["dbt", "logging", "data", "analytics"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.8",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.poetry.scripts]
|
|
23
|
+
dbt-logger = "dbt_logger.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.poetry.dependencies]
|
|
26
|
+
python = "^3.9.2,<3.13"
|
|
27
|
+
snowflake-connector-python = "^3.0.0"
|
|
28
|
+
pyyaml = "^6.0.0"
|
|
29
|
+
cryptography = "^41.0.0"
|
|
30
|
+
|
|
31
|
+
[tool.poetry.group.dev.dependencies]
|
|
32
|
+
pytest = "^7.0.0"
|
|
33
|
+
pre-commit = "^3.3.3"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["poetry-core"]
|
|
37
|
+
build-backend = "poetry.core.masonry.api"
|
|
38
|
+
|
|
39
|
+
[[tool.poetry.source]]
|
|
40
|
+
name = "PyPI"
|
|
41
|
+
priority = "primary"
|