bqlens 0.1.0__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.
- bqlens-0.1.0/LICENSE +15 -0
- bqlens-0.1.0/PKG-INFO +163 -0
- bqlens-0.1.0/README.md +144 -0
- bqlens-0.1.0/bqlens/__init__.py +3 -0
- bqlens-0.1.0/bqlens/cli.py +135 -0
- bqlens-0.1.0/bqlens/demo.py +188 -0
- bqlens-0.1.0/bqlens/fetch.py +65 -0
- bqlens-0.1.0/bqlens/models.py +161 -0
- bqlens-0.1.0/bqlens/report.py +239 -0
- bqlens-0.1.0/bqlens/rules.py +526 -0
- bqlens-0.1.0/bqlens.egg-info/PKG-INFO +163 -0
- bqlens-0.1.0/bqlens.egg-info/SOURCES.txt +17 -0
- bqlens-0.1.0/bqlens.egg-info/dependency_links.txt +1 -0
- bqlens-0.1.0/bqlens.egg-info/entry_points.txt +2 -0
- bqlens-0.1.0/bqlens.egg-info/requires.txt +6 -0
- bqlens-0.1.0/bqlens.egg-info/top_level.txt +1 -0
- bqlens-0.1.0/pyproject.toml +32 -0
- bqlens-0.1.0/setup.cfg +4 -0
- bqlens-0.1.0/tests/test_rules.py +258 -0
bqlens-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License 2.0
|
|
2
|
+
|
|
3
|
+
Copyright 2026 bqlens contributors
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
bqlens-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bqlens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find wasted BigQuery spend in one command. Read-only.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: bigquery,gcp,finops,cost-optimization,data-engineering
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Database
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: bigquery
|
|
15
|
+
Requires-Dist: google-cloud-bigquery>=3.11; extra == "bigquery"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# bqlens
|
|
21
|
+
|
|
22
|
+
**Find wasted BigQuery spend in one command.**
|
|
23
|
+
|
|
24
|
+
No signup. No sales call. No agent installed in your VPC. It reads your query
|
|
25
|
+
history, tells you where the money is going, and tells you how to stop it.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install bqlens
|
|
29
|
+
bqlens scan --project my-project --days 30
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
bqlens — my-project
|
|
34
|
+
1,833 query jobs over the last 30 days
|
|
35
|
+
|
|
36
|
+
Spend in window $ 1,946.10
|
|
37
|
+
Projected monthly $ 1,946.10
|
|
38
|
+
Recoverable $ 572.50/mo (29% of spend)
|
|
39
|
+
|
|
40
|
+
1. [HIGH] Wildcard table scans without _TABLE_SUFFIX (R004)
|
|
41
|
+
$179.30/mo recoverable · 22 jobs
|
|
42
|
+
...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Try it with no credentials at all:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bqlens scan --demo
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## What it looks at
|
|
54
|
+
|
|
55
|
+
bqlens reads `INFORMATION_SCHEMA.JOBS_BY_PROJECT` — job metadata and the SQL
|
|
56
|
+
text of your queries.
|
|
57
|
+
|
|
58
|
+
**It never reads a row of your table data.** There is no row access, no
|
|
59
|
+
sampling, no data leaving your project. The whole tool is a read of your own
|
|
60
|
+
query log plus regex and arithmetic, running on your machine.
|
|
61
|
+
|
|
62
|
+
## What it finds
|
|
63
|
+
|
|
64
|
+
| Rule | What it catches | Why it costs money |
|
|
65
|
+
|------|-----------------|--------------------|
|
|
66
|
+
| R001 | `SELECT *` on large scans | BigQuery bills per column read |
|
|
67
|
+
| R002 | `LIMIT` with no `WHERE` | LIMIT caps rows returned, not bytes scanned |
|
|
68
|
+
| R003 | Large scans with no filter | No partition pruning |
|
|
69
|
+
| R004 | Wildcard tables without `_TABLE_SUFFIX` | Scans every table in the dataset |
|
|
70
|
+
| R005 | The same query shape run over and over | One materialised refresh replaces all of them |
|
|
71
|
+
| R006 | Failed jobs that were still billed | Money that bought nothing |
|
|
72
|
+
| R007 | Cross joins / joins missing `ON` | Row multiplication |
|
|
73
|
+
|
|
74
|
+
## Every dollar is counted once
|
|
75
|
+
|
|
76
|
+
A repeated `SELECT * ... LIMIT` matches three rules at once. If each rule
|
|
77
|
+
counted it, the reported savings would add up to more than your actual bill —
|
|
78
|
+
and the first thing a sceptical engineer does is add up our numbers.
|
|
79
|
+
|
|
80
|
+
So rules run in priority order and each job is claimed by exactly one rule,
|
|
81
|
+
the one with the most specific fix. The sum of findings can never exceed
|
|
82
|
+
actual spend, and there is a test that fails the build if it ever does.
|
|
83
|
+
|
|
84
|
+
Savings estimates are deliberately conservative. bqlens counts what a stated
|
|
85
|
+
fix would plausibly recover, not the full cost of the query. If it says $500,
|
|
86
|
+
the intent is that you find $500, not that you find $180 and stop trusting it.
|
|
87
|
+
|
|
88
|
+
## Usage
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Basic scan
|
|
92
|
+
bqlens scan --project my-project
|
|
93
|
+
|
|
94
|
+
# Non-US region (the JOBS view is region-qualified)
|
|
95
|
+
bqlens scan --project my-project --region region-asia-south1
|
|
96
|
+
|
|
97
|
+
# Your negotiated rate rather than list price
|
|
98
|
+
bqlens scan --project my-project --price-per-tib 5.00
|
|
99
|
+
|
|
100
|
+
# A page you can forward to your manager
|
|
101
|
+
bqlens scan --project my-project --format html --out report.html
|
|
102
|
+
|
|
103
|
+
# Machine-readable
|
|
104
|
+
bqlens scan --project my-project --format json
|
|
105
|
+
|
|
106
|
+
# Fail CI if waste creeps above a threshold
|
|
107
|
+
bqlens scan --project my-project --fail-over 200
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Options
|
|
111
|
+
|
|
112
|
+
| Flag | Default | Meaning |
|
|
113
|
+
|------|---------|---------|
|
|
114
|
+
| `--project` | — | GCP project to scan (required unless `--demo`) |
|
|
115
|
+
| `--region` | `region-us` | Region of the JOBS view |
|
|
116
|
+
| `--days` | `30` | Days of history |
|
|
117
|
+
| `--price-per-tib` | `6.25` | On-demand price per TiB scanned |
|
|
118
|
+
| `--min-gib` | `1.0` | Ignore queries smaller than this |
|
|
119
|
+
| `--min-repeats` | `10` | Repeats before a shape is flagged |
|
|
120
|
+
| `--format` | `terminal` | `terminal`, `json`, or `html` |
|
|
121
|
+
| `--out` | — | Write to a file |
|
|
122
|
+
| `--fail-over` | — | Exit 1 if monthly recoverable exceeds this |
|
|
123
|
+
| `--demo` | — | Run on synthetic data, no credentials |
|
|
124
|
+
|
|
125
|
+
## Permissions
|
|
126
|
+
|
|
127
|
+
The scanning account needs `bigquery.jobs.listAll` on the project — included
|
|
128
|
+
in `roles/bigquery.resourceAdmin`, or grant it directly. Without `listAll` you
|
|
129
|
+
will only see your own jobs, not the whole project's.
|
|
130
|
+
|
|
131
|
+
The metadata query is itself capped at 10 GiB billed, so the scanner can never
|
|
132
|
+
become the expensive thing in your bill.
|
|
133
|
+
|
|
134
|
+
## Install
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install bqlens # CLI + demo mode
|
|
138
|
+
pip install 'bqlens[bigquery]' # adds the BigQuery client for real scans
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Python 3.10+. Authenticate with `gcloud auth application-default login`.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install -e '.[dev]'
|
|
147
|
+
pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Limitations, stated plainly
|
|
151
|
+
|
|
152
|
+
- Rules work on query text and job metadata. bqlens does not read table schemas,
|
|
153
|
+
so it cannot know whether a table is partitioned — R003 flags unfiltered scans
|
|
154
|
+
as a *candidate*, not a certainty.
|
|
155
|
+
- Savings percentages per rule are heuristics based on typical wide analytics
|
|
156
|
+
tables. Your mileage varies.
|
|
157
|
+
- Slot-based (capacity) pricing is not yet modelled; cost figures assume
|
|
158
|
+
on-demand. Reservation support is planned.
|
|
159
|
+
- SQL parsing is regex-based, not a full parser. Exotic queries may be missed.
|
|
160
|
+
|
|
161
|
+
## Licence
|
|
162
|
+
|
|
163
|
+
Apache-2.0
|
bqlens-0.1.0/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# bqlens
|
|
2
|
+
|
|
3
|
+
**Find wasted BigQuery spend in one command.**
|
|
4
|
+
|
|
5
|
+
No signup. No sales call. No agent installed in your VPC. It reads your query
|
|
6
|
+
history, tells you where the money is going, and tells you how to stop it.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install bqlens
|
|
10
|
+
bqlens scan --project my-project --days 30
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
bqlens — my-project
|
|
15
|
+
1,833 query jobs over the last 30 days
|
|
16
|
+
|
|
17
|
+
Spend in window $ 1,946.10
|
|
18
|
+
Projected monthly $ 1,946.10
|
|
19
|
+
Recoverable $ 572.50/mo (29% of spend)
|
|
20
|
+
|
|
21
|
+
1. [HIGH] Wildcard table scans without _TABLE_SUFFIX (R004)
|
|
22
|
+
$179.30/mo recoverable · 22 jobs
|
|
23
|
+
...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Try it with no credentials at all:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bqlens scan --demo
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## What it looks at
|
|
35
|
+
|
|
36
|
+
bqlens reads `INFORMATION_SCHEMA.JOBS_BY_PROJECT` — job metadata and the SQL
|
|
37
|
+
text of your queries.
|
|
38
|
+
|
|
39
|
+
**It never reads a row of your table data.** There is no row access, no
|
|
40
|
+
sampling, no data leaving your project. The whole tool is a read of your own
|
|
41
|
+
query log plus regex and arithmetic, running on your machine.
|
|
42
|
+
|
|
43
|
+
## What it finds
|
|
44
|
+
|
|
45
|
+
| Rule | What it catches | Why it costs money |
|
|
46
|
+
|------|-----------------|--------------------|
|
|
47
|
+
| R001 | `SELECT *` on large scans | BigQuery bills per column read |
|
|
48
|
+
| R002 | `LIMIT` with no `WHERE` | LIMIT caps rows returned, not bytes scanned |
|
|
49
|
+
| R003 | Large scans with no filter | No partition pruning |
|
|
50
|
+
| R004 | Wildcard tables without `_TABLE_SUFFIX` | Scans every table in the dataset |
|
|
51
|
+
| R005 | The same query shape run over and over | One materialised refresh replaces all of them |
|
|
52
|
+
| R006 | Failed jobs that were still billed | Money that bought nothing |
|
|
53
|
+
| R007 | Cross joins / joins missing `ON` | Row multiplication |
|
|
54
|
+
|
|
55
|
+
## Every dollar is counted once
|
|
56
|
+
|
|
57
|
+
A repeated `SELECT * ... LIMIT` matches three rules at once. If each rule
|
|
58
|
+
counted it, the reported savings would add up to more than your actual bill —
|
|
59
|
+
and the first thing a sceptical engineer does is add up our numbers.
|
|
60
|
+
|
|
61
|
+
So rules run in priority order and each job is claimed by exactly one rule,
|
|
62
|
+
the one with the most specific fix. The sum of findings can never exceed
|
|
63
|
+
actual spend, and there is a test that fails the build if it ever does.
|
|
64
|
+
|
|
65
|
+
Savings estimates are deliberately conservative. bqlens counts what a stated
|
|
66
|
+
fix would plausibly recover, not the full cost of the query. If it says $500,
|
|
67
|
+
the intent is that you find $500, not that you find $180 and stop trusting it.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Basic scan
|
|
73
|
+
bqlens scan --project my-project
|
|
74
|
+
|
|
75
|
+
# Non-US region (the JOBS view is region-qualified)
|
|
76
|
+
bqlens scan --project my-project --region region-asia-south1
|
|
77
|
+
|
|
78
|
+
# Your negotiated rate rather than list price
|
|
79
|
+
bqlens scan --project my-project --price-per-tib 5.00
|
|
80
|
+
|
|
81
|
+
# A page you can forward to your manager
|
|
82
|
+
bqlens scan --project my-project --format html --out report.html
|
|
83
|
+
|
|
84
|
+
# Machine-readable
|
|
85
|
+
bqlens scan --project my-project --format json
|
|
86
|
+
|
|
87
|
+
# Fail CI if waste creeps above a threshold
|
|
88
|
+
bqlens scan --project my-project --fail-over 200
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Options
|
|
92
|
+
|
|
93
|
+
| Flag | Default | Meaning |
|
|
94
|
+
|------|---------|---------|
|
|
95
|
+
| `--project` | — | GCP project to scan (required unless `--demo`) |
|
|
96
|
+
| `--region` | `region-us` | Region of the JOBS view |
|
|
97
|
+
| `--days` | `30` | Days of history |
|
|
98
|
+
| `--price-per-tib` | `6.25` | On-demand price per TiB scanned |
|
|
99
|
+
| `--min-gib` | `1.0` | Ignore queries smaller than this |
|
|
100
|
+
| `--min-repeats` | `10` | Repeats before a shape is flagged |
|
|
101
|
+
| `--format` | `terminal` | `terminal`, `json`, or `html` |
|
|
102
|
+
| `--out` | — | Write to a file |
|
|
103
|
+
| `--fail-over` | — | Exit 1 if monthly recoverable exceeds this |
|
|
104
|
+
| `--demo` | — | Run on synthetic data, no credentials |
|
|
105
|
+
|
|
106
|
+
## Permissions
|
|
107
|
+
|
|
108
|
+
The scanning account needs `bigquery.jobs.listAll` on the project — included
|
|
109
|
+
in `roles/bigquery.resourceAdmin`, or grant it directly. Without `listAll` you
|
|
110
|
+
will only see your own jobs, not the whole project's.
|
|
111
|
+
|
|
112
|
+
The metadata query is itself capped at 10 GiB billed, so the scanner can never
|
|
113
|
+
become the expensive thing in your bill.
|
|
114
|
+
|
|
115
|
+
## Install
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install bqlens # CLI + demo mode
|
|
119
|
+
pip install 'bqlens[bigquery]' # adds the BigQuery client for real scans
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Python 3.10+. Authenticate with `gcloud auth application-default login`.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install -e '.[dev]'
|
|
128
|
+
pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Limitations, stated plainly
|
|
132
|
+
|
|
133
|
+
- Rules work on query text and job metadata. bqlens does not read table schemas,
|
|
134
|
+
so it cannot know whether a table is partitioned — R003 flags unfiltered scans
|
|
135
|
+
as a *candidate*, not a certainty.
|
|
136
|
+
- Savings percentages per rule are heuristics based on typical wide analytics
|
|
137
|
+
tables. Your mileage varies.
|
|
138
|
+
- Slot-based (capacity) pricing is not yet modelled; cost figures assume
|
|
139
|
+
on-demand. Reservation support is planned.
|
|
140
|
+
- SQL parsing is regex-based, not a full parser. Exotic queries may be missed.
|
|
141
|
+
|
|
142
|
+
## Licence
|
|
143
|
+
|
|
144
|
+
Apache-2.0
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command line interface for bqlens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .models import DEFAULT_PRICE_PER_TIB, ScanResult
|
|
11
|
+
from .report import render_html, render_json, render_terminal
|
|
12
|
+
from .rules import run_all
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="bqlens",
|
|
18
|
+
description="Find wasted BigQuery spend. Read-only; never touches your table data.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--version", action="version", version=f"bqlens {__version__}")
|
|
21
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
scan = sub.add_parser("scan", help="Scan query history for recoverable spend")
|
|
24
|
+
scan.add_argument("--project", help="GCP project ID to scan")
|
|
25
|
+
scan.add_argument(
|
|
26
|
+
"--region",
|
|
27
|
+
default="region-us",
|
|
28
|
+
help="BigQuery region of the JOBS view (default: region-us)",
|
|
29
|
+
)
|
|
30
|
+
scan.add_argument("--days", type=int, default=30, help="Days of history (default: 30)")
|
|
31
|
+
scan.add_argument(
|
|
32
|
+
"--price-per-tib",
|
|
33
|
+
type=float,
|
|
34
|
+
default=DEFAULT_PRICE_PER_TIB,
|
|
35
|
+
help=f"On-demand price per TiB scanned (default: {DEFAULT_PRICE_PER_TIB})",
|
|
36
|
+
)
|
|
37
|
+
scan.add_argument(
|
|
38
|
+
"--min-gib",
|
|
39
|
+
type=float,
|
|
40
|
+
default=1.0,
|
|
41
|
+
help="Ignore queries smaller than this many GiB (default: 1.0)",
|
|
42
|
+
)
|
|
43
|
+
scan.add_argument(
|
|
44
|
+
"--min-repeats",
|
|
45
|
+
type=int,
|
|
46
|
+
default=10,
|
|
47
|
+
help="Repeats before a query shape is flagged for materialisation (default: 10)",
|
|
48
|
+
)
|
|
49
|
+
scan.add_argument(
|
|
50
|
+
"--format",
|
|
51
|
+
choices=["terminal", "json", "html"],
|
|
52
|
+
default="terminal",
|
|
53
|
+
help="Output format (default: terminal)",
|
|
54
|
+
)
|
|
55
|
+
scan.add_argument("--out", help="Write output to this file instead of stdout")
|
|
56
|
+
scan.add_argument("--no-color", action="store_true", help="Disable ANSI colour")
|
|
57
|
+
scan.add_argument(
|
|
58
|
+
"--demo",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Run against built-in synthetic history. No credentials needed.",
|
|
61
|
+
)
|
|
62
|
+
scan.add_argument(
|
|
63
|
+
"--fail-over",
|
|
64
|
+
type=float,
|
|
65
|
+
default=None,
|
|
66
|
+
metavar="USD",
|
|
67
|
+
help="Exit non-zero if monthly recoverable spend exceeds this. For CI.",
|
|
68
|
+
)
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
73
|
+
if args.demo:
|
|
74
|
+
from .demo import generate
|
|
75
|
+
|
|
76
|
+
jobs = generate()
|
|
77
|
+
project = "demo-project"
|
|
78
|
+
else:
|
|
79
|
+
if not args.project:
|
|
80
|
+
print("error: --project is required (or use --demo)", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
from .fetch import fetch_jobs
|
|
83
|
+
|
|
84
|
+
jobs = fetch_jobs(project=args.project, region=args.region, days=args.days)
|
|
85
|
+
project = args.project
|
|
86
|
+
|
|
87
|
+
if not jobs:
|
|
88
|
+
print("No query jobs found in that window.", file=sys.stderr)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
min_bytes = int(args.min_gib * 1024**3)
|
|
92
|
+
findings = run_all(
|
|
93
|
+
jobs,
|
|
94
|
+
price_per_tib=args.price_per_tib,
|
|
95
|
+
min_bytes=min_bytes,
|
|
96
|
+
min_repeats=args.min_repeats,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
result = ScanResult(
|
|
100
|
+
findings=findings,
|
|
101
|
+
total_jobs=len(jobs),
|
|
102
|
+
total_spend_usd=sum(j.cost(args.price_per_tib) for j in jobs),
|
|
103
|
+
days=args.days,
|
|
104
|
+
project=project,
|
|
105
|
+
price_per_tib=args.price_per_tib,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if args.format == "json":
|
|
109
|
+
output = render_json(result)
|
|
110
|
+
elif args.format == "html":
|
|
111
|
+
output = render_html(result)
|
|
112
|
+
else:
|
|
113
|
+
colour = sys.stdout.isatty() and not args.no_color
|
|
114
|
+
output = render_terminal(result, colour=colour)
|
|
115
|
+
|
|
116
|
+
if args.out:
|
|
117
|
+
Path(args.out).write_text(output, encoding="utf-8")
|
|
118
|
+
print(f"Wrote {args.out}", file=sys.stderr)
|
|
119
|
+
else:
|
|
120
|
+
print(output)
|
|
121
|
+
|
|
122
|
+
if args.fail_over is not None and result.monthly_recoverable_usd > args.fail_over:
|
|
123
|
+
return 1
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: list[str] | None = None) -> int:
|
|
128
|
+
args = build_parser().parse_args(argv)
|
|
129
|
+
if args.command == "scan":
|
|
130
|
+
return cmd_scan(args)
|
|
131
|
+
return 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Synthetic job history so the scanner can be tried without credentials.
|
|
2
|
+
|
|
3
|
+
`bqlens scan --demo` runs the full pipeline against this. It is also what the
|
|
4
|
+
test suite asserts against, so the numbers below are fixtures, not decoration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
from .models import Job
|
|
13
|
+
|
|
14
|
+
GIB = 1024**3
|
|
15
|
+
|
|
16
|
+
_USERS = [
|
|
17
|
+
"analytics@example.com",
|
|
18
|
+
"dbt-runner@example.iam.gserviceaccount.com",
|
|
19
|
+
"looker@example.iam.gserviceaccount.com",
|
|
20
|
+
"priya@example.com",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _job(
|
|
25
|
+
idx: int,
|
|
26
|
+
query: str,
|
|
27
|
+
gib: float,
|
|
28
|
+
user: str,
|
|
29
|
+
*,
|
|
30
|
+
cache_hit: bool = False,
|
|
31
|
+
error: str | None = None,
|
|
32
|
+
when: datetime | None = None,
|
|
33
|
+
) -> Job:
|
|
34
|
+
billed = int(gib * GIB)
|
|
35
|
+
return Job(
|
|
36
|
+
job_id=f"demo_job_{idx:05d}",
|
|
37
|
+
user_email=user,
|
|
38
|
+
creation_time=when or (datetime.utcnow() - timedelta(hours=idx % 720)),
|
|
39
|
+
query=query,
|
|
40
|
+
total_bytes_billed=0 if cache_hit else billed,
|
|
41
|
+
total_bytes_processed=billed,
|
|
42
|
+
cache_hit=cache_hit,
|
|
43
|
+
statement_type="SELECT",
|
|
44
|
+
referenced_tables=["demo-project.warehouse.events"],
|
|
45
|
+
total_slot_ms=int(gib * 1200),
|
|
46
|
+
error_result=error,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate(seed: int = 7) -> list[Job]:
|
|
51
|
+
"""A plausible 30 days of query history for a mid-sized data team."""
|
|
52
|
+
rng = random.Random(seed)
|
|
53
|
+
jobs: list[Job] = []
|
|
54
|
+
idx = 0
|
|
55
|
+
|
|
56
|
+
# 1. A Looker dashboard hammering the same unaggregated query all month.
|
|
57
|
+
dashboard_sql = """
|
|
58
|
+
SELECT user_id, event_name, event_timestamp, platform, country,
|
|
59
|
+
session_id, revenue_usd
|
|
60
|
+
FROM `demo-project.warehouse.events`
|
|
61
|
+
WHERE event_date >= '2026-08-01'
|
|
62
|
+
"""
|
|
63
|
+
for _ in range(280):
|
|
64
|
+
idx += 1
|
|
65
|
+
jobs.append(_job(idx, dashboard_sql, rng.uniform(7.5, 9.0), "looker@example.iam.gserviceaccount.com"))
|
|
66
|
+
|
|
67
|
+
# 2. GA4-style wildcard scans with no _TABLE_SUFFIX filter. The classic.
|
|
68
|
+
wildcard_sql = """
|
|
69
|
+
SELECT event_name, COUNT(*) AS n
|
|
70
|
+
FROM `demo-project.analytics_311.events_*`
|
|
71
|
+
GROUP BY event_name
|
|
72
|
+
"""
|
|
73
|
+
for _ in range(22):
|
|
74
|
+
idx += 1
|
|
75
|
+
jobs.append(_job(idx, wildcard_sql, rng.uniform(140, 190), "analytics@example.com"))
|
|
76
|
+
|
|
77
|
+
# 3. Analysts exploring with SELECT * LIMIT, believing LIMIT is cheap.
|
|
78
|
+
for _ in range(46):
|
|
79
|
+
idx += 1
|
|
80
|
+
jobs.append(
|
|
81
|
+
_job(
|
|
82
|
+
idx,
|
|
83
|
+
"SELECT * FROM `demo-project.warehouse.events` LIMIT 100",
|
|
84
|
+
rng.uniform(58, 72),
|
|
85
|
+
"priya@example.com",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 4. SELECT * feeding a transform that uses four columns.
|
|
90
|
+
for _ in range(35):
|
|
91
|
+
idx += 1
|
|
92
|
+
jobs.append(
|
|
93
|
+
_job(
|
|
94
|
+
idx,
|
|
95
|
+
"SELECT * FROM `demo-project.warehouse.transactions` "
|
|
96
|
+
"WHERE txn_date BETWEEN '2026-08-01' AND '2026-08-31'",
|
|
97
|
+
rng.uniform(18, 26),
|
|
98
|
+
"dbt-runner@example.iam.gserviceaccount.com",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 5. A broken scheduled query retrying all month and billing every time.
|
|
103
|
+
for _ in range(64):
|
|
104
|
+
idx += 1
|
|
105
|
+
jobs.append(
|
|
106
|
+
_job(
|
|
107
|
+
idx,
|
|
108
|
+
"SELECT customer_id, SUM(amount) FROM `demo-project.warehouse.ledger` "
|
|
109
|
+
"WHERE posted_date >= '2026-08-01' GROUP BY 1",
|
|
110
|
+
rng.uniform(11, 15),
|
|
111
|
+
"dbt-runner@example.iam.gserviceaccount.com",
|
|
112
|
+
error="Not found: Table demo-project:warehouse.ledger_v2",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 6. An unfiltered full scan somebody schedules nightly.
|
|
117
|
+
for _ in range(30):
|
|
118
|
+
idx += 1
|
|
119
|
+
jobs.append(
|
|
120
|
+
_job(
|
|
121
|
+
idx,
|
|
122
|
+
"SELECT customer_id, lifetime_value, segment "
|
|
123
|
+
"FROM `demo-project.warehouse.customer_360`",
|
|
124
|
+
rng.uniform(34, 44),
|
|
125
|
+
"analytics@example.com",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# 7. A join missing its ON clause.
|
|
130
|
+
for _ in range(6):
|
|
131
|
+
idx += 1
|
|
132
|
+
jobs.append(
|
|
133
|
+
_job(
|
|
134
|
+
idx,
|
|
135
|
+
"SELECT a.user_id, b.campaign_id "
|
|
136
|
+
"FROM `demo-project.warehouse.users` a "
|
|
137
|
+
"JOIN `demo-project.warehouse.campaigns` b "
|
|
138
|
+
"WHERE a.country = 'IN'",
|
|
139
|
+
rng.uniform(25, 33),
|
|
140
|
+
"priya@example.com",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# 8. Plenty of ordinary, well-written, cheap queries — the healthy majority.
|
|
145
|
+
for _ in range(900):
|
|
146
|
+
idx += 1
|
|
147
|
+
col = rng.choice(["event_name", "platform", "country"])
|
|
148
|
+
jobs.append(
|
|
149
|
+
_job(
|
|
150
|
+
idx,
|
|
151
|
+
f"SELECT {col}, COUNT(*) FROM `demo-project.warehouse.events` "
|
|
152
|
+
f"WHERE event_date = '2026-09-{rng.randint(1, 17):02d}' GROUP BY 1",
|
|
153
|
+
rng.uniform(0.05, 0.9),
|
|
154
|
+
rng.choice(_USERS),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# 9. Legitimate heavy analytical work: large, well-written, distinct
|
|
159
|
+
# queries. This is the bulk of a healthy bill and nothing should flag
|
|
160
|
+
# it. Without this block the demo would claim an absurd savings rate.
|
|
161
|
+
_dims = ["country", "platform", "channel", "device_type", "cohort", "tier", "region"]
|
|
162
|
+
_metrics = ["revenue_usd", "sessions", "orders", "refunds", "margin_usd", "units"]
|
|
163
|
+
_tables = ["transactions", "sessions_daily", "orders", "subscriptions", "inventory_daily"]
|
|
164
|
+
for i in range(300):
|
|
165
|
+
idx += 1
|
|
166
|
+
dim = _dims[i % len(_dims)]
|
|
167
|
+
metric = _metrics[(i // 7) % len(_metrics)]
|
|
168
|
+
table = _tables[(i // 3) % len(_tables)]
|
|
169
|
+
jobs.append(
|
|
170
|
+
_job(
|
|
171
|
+
idx,
|
|
172
|
+
f"SELECT {dim}, DATE_TRUNC(txn_date, MONTH) AS m, SUM({metric}) AS total_{i} "
|
|
173
|
+
f"FROM `demo-project.warehouse.{table}` t "
|
|
174
|
+
f"JOIN `demo-project.warehouse.dim_customer` c ON t.customer_id = c.customer_id "
|
|
175
|
+
f"WHERE txn_date >= '2026-0{rng.randint(1, 8)}-01' "
|
|
176
|
+
f"GROUP BY 1, 2",
|
|
177
|
+
rng.uniform(40, 90),
|
|
178
|
+
rng.choice(_USERS),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# 10. Cache hits — free, and correctly ignored by every rule.
|
|
183
|
+
for _ in range(150):
|
|
184
|
+
idx += 1
|
|
185
|
+
jobs.append(_job(idx, dashboard_sql, 8.0, "looker@example.iam.gserviceaccount.com", cache_hit=True))
|
|
186
|
+
|
|
187
|
+
rng.shuffle(jobs)
|
|
188
|
+
return jobs
|