-
Couldn't load subscription status.
- Fork 11
feat(FIR-9997): Usage tracking #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
1492d3e
feat: Usage tracking
ptiurin 6f4fc77
adding docstrings
ptiurin 290dd61
adding integration tests
ptiurin 82a1953
Cleanup and refactor
ptiurin 96f7a02
More efficient test
ptiurin dc2df15
Fix black issue
ptiurin 63b4f20
Fix backwards compatibility
ptiurin c08025b
Fix caching invalidation
ptiurin becb670
mypy fix
ptiurin 4cdf4f7
Merge changes
ptiurin 16341f6
Simplyfy the design
ptiurin 91fec94
Argument to connect
ptiurin 373451d
Get rid of the class
ptiurin b4aa6b1
More dynamic code
ptiurin ff7d833
Inline
ptiurin 5f25708
Better join
ptiurin e312f28
Pathlib
ptiurin 440e7cf
Fix merge error
ptiurin 6714a30
Fix mypy
ptiurin 11b6945
Fix unit tests
ptiurin 60b6ca8
Better fix
ptiurin 5fbd561
Verifying versions
ptiurin 4c25233
Better env in int testing
ptiurin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import inspect | ||
| import logging | ||
| from importlib import import_module | ||
| from pathlib import Path | ||
| from platform import python_version, release, system | ||
| from sys import modules | ||
| from typing import Dict, List, Optional, Tuple | ||
|
|
||
| from pydantic import BaseModel | ||
|
|
||
| from firebolt import __version__ | ||
|
|
||
|
|
||
| class ConnectorVersions(BaseModel): | ||
| """ | ||
| Verify correct parameter types | ||
| """ | ||
|
|
||
| versions: List[Tuple[str, str]] | ||
|
|
||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| CONNECTOR_MAP = [ | ||
| ( | ||
| "DBT", | ||
| "open", | ||
| Path("dbt/adapters/firebolt/connections.py"), | ||
| "dbt.adapters.firebolt", | ||
| ), | ||
| ( | ||
| "Airflow", | ||
| "get_conn", | ||
| Path("firebolt_provider/hooks/firebolt.py"), | ||
| "firebolt_provider", | ||
| ), | ||
| ( | ||
| "AirbyteDestination", | ||
| "establish_connection", | ||
| Path("destination_firebolt/destination.py"), | ||
| "", | ||
| ), | ||
| ( | ||
| "AirbyteDestination", | ||
| "establish_async_connection", | ||
| Path("destination_firebolt/destination.py"), | ||
| "", | ||
| ), | ||
| ("AirbyteSource", "establish_connection", Path("source_firebolt/source.py"), ""), | ||
| ( | ||
| "AirbyteSource", | ||
| "establish_async_connection", | ||
| Path("source_firebolt/source.py"), | ||
| "", | ||
| ), | ||
| ("SQLAlchemy", "connect", Path("sqlalchemy/engine/default.py"), "firebolt_db"), | ||
| ("FireboltCLI", "create_connection", Path("firebolt_cli/utils.py"), "firebolt_cli"), | ||
| ] | ||
|
|
||
|
|
||
| def _os_compare(file: Path, expected: Path) -> bool: | ||
| """ | ||
| System-independent path comparison. | ||
|
|
||
| Args: | ||
| file: file path to check against | ||
| expected: expected file path | ||
|
|
||
| Returns: | ||
| True if file ends with path | ||
| """ | ||
| return file.parts[-len(expected.parts) :] == expected.parts | ||
|
|
||
|
|
||
| def get_sdk_properties() -> Tuple[str, str, str, str]: | ||
| """ | ||
| Detect Python, OS and SDK versions. | ||
|
|
||
| Returns: | ||
| Python version, SDK version, OS name and "ciso" if imported | ||
| """ | ||
| py_version = python_version() | ||
| sdk_version = __version__ | ||
| os_version = f"{system()} {release()}" | ||
| ciso = "ciso8601" if "ciso8601" in modules.keys() else "" | ||
| logger.debug( | ||
| "Python %s detected. SDK %s OS %s %s", | ||
| py_version, | ||
| sdk_version, | ||
| os_version, | ||
| ciso, | ||
| ) | ||
| return (py_version, sdk_version, os_version, ciso) | ||
|
|
||
|
|
||
| def detect_connectors() -> Dict[str, str]: | ||
| """ | ||
| Detect which connectors are running the code by parsing the stack. | ||
| Exceptions are ignored since this is intended for logging only. | ||
| """ | ||
| connectors: Dict[str, str] = {} | ||
| stack = inspect.stack() | ||
| for f in stack: | ||
| try: | ||
| for name, func, path, version_path in CONNECTOR_MAP: | ||
| if f.function == func and _os_compare(Path(f.filename), path): | ||
| if version_path: | ||
| m = import_module(version_path) | ||
| connectors[name] = m.__version__ # type: ignore | ||
| else: | ||
| # Some connectors don't have versions specified | ||
| connectors[name] = "" | ||
| # No need to carry on if connector is detected | ||
| break | ||
| except Exception: | ||
| logger.debug( | ||
| "Failed to extract version from %s in %s", f.function, f.filename | ||
| ) | ||
| return connectors | ||
|
|
||
|
|
||
| def format_as_user_agent(connectors: Dict[str, str]) -> str: | ||
| """ | ||
| Return a representation of a stored tracking data as a user-agent header. | ||
|
|
||
| Args: | ||
| connectors: Dictionary of connector to version mappings | ||
|
|
||
| Returns: | ||
| String of the current detected connector stack. | ||
| """ | ||
| py, sdk, os, ciso = get_sdk_properties() | ||
| sdk_format = f"PythonSDK/{sdk} (Python {py}; {os}; {ciso})" | ||
| connector_format = "".join( | ||
| [f" {connector}/{version}" for connector, version in connectors.items()] | ||
| ) | ||
| return sdk_format + connector_format | ||
|
|
||
|
|
||
| def get_user_agent_header( | ||
| connector_versions: Optional[List[Tuple[str, str]]] = [] | ||
| ) -> str: | ||
| """ | ||
| Return a user agent header with connector stack and system information. | ||
|
|
||
| Args: | ||
| connector_versions(Optional): User-supplied list of tuples of all connectors | ||
| and their versions intended for tracking. | ||
|
|
||
| Returns: | ||
| String representation of a user-agent tracking information | ||
| """ | ||
| connectors = detect_connectors() | ||
| logger.debug("Detected running from packages: %s", str(connectors)) | ||
| # Override auto-detected connectors with info provided manually | ||
| for name, version in ConnectorVersions(versions=connector_versions).versions: | ||
| connectors[name] = version | ||
| return format_as_user_agent(connectors) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import sys | ||
|
|
||
| # Hack to avoid detecting current file as firebolt module | ||
| old_path = sys.path | ||
| sys.path = sys.path[1:] | ||
| from firebolt.utils.usage_tracker import get_user_agent_header | ||
|
|
||
| # Back to old path for detection to work properly | ||
| sys.path = old_path | ||
|
|
||
|
|
||
| def {function_name}(): | ||
| print(get_user_agent_header()) | ||
|
|
||
| {function_name}() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import os | ||
| from pathlib import Path | ||
| from shutil import rmtree | ||
| from subprocess import PIPE, run | ||
|
|
||
| from pytest import fixture, mark | ||
|
|
||
| TEST_FOLDER = "tmp_test_code/" | ||
| TEST_SCRIPT_MODEL = "tests/integration/utils/sample_usage.model" | ||
|
|
||
|
|
||
| MOCK_MODULES = [ | ||
| "firebolt_cli/firebolt_cli.py", | ||
| "sqlalchemy/engine/firebolt_db.py", | ||
| "firebolt_provider/hooks/firebolt_provider.py", | ||
| "dbt/adapters/firebolt/dbt/adapters/firebolt.py", | ||
| ] | ||
|
|
||
|
|
||
| @fixture(scope="module", autouse=True) | ||
| def create_cli_mock(): | ||
| for i, file in enumerate(MOCK_MODULES): | ||
| os.makedirs(os.path.dirname(f"{TEST_FOLDER}{file}")) | ||
| with open(f"{TEST_FOLDER}{file}", "w") as f: | ||
| f.write(f"__version__ = '1.0.{i}'") | ||
| # Additional setup for proper dbt import | ||
| Path(f"{TEST_FOLDER}dbt/adapters/firebolt/dbt/__init__.py").touch() | ||
| Path(f"{TEST_FOLDER}/dbt/adapters/firebolt/dbt/adapters/__init__.py").touch() | ||
| yield | ||
| rmtree(TEST_FOLDER) | ||
|
|
||
|
|
||
| @fixture(scope="module") | ||
| def test_model(): | ||
| with open(TEST_SCRIPT_MODEL) as f: | ||
| return f.read() | ||
|
|
||
|
|
||
| def create_test_file(code: str, function_name: str, file_path: str): | ||
| code = code.format(function_name=function_name) | ||
| os.makedirs(os.path.dirname(file_path), exist_ok=True) | ||
| with open(file_path, "w") as f: | ||
| f.write(code) | ||
|
|
||
|
|
||
| @mark.parametrize( | ||
| "function,path,expected", | ||
| [ | ||
| ("create_connection", "firebolt_cli/utils.py", "FireboltCLI/1.0.0"), | ||
| ("connect", "sqlalchemy/engine/default.py", "SQLAlchemy/1.0.1"), | ||
| ("establish_connection", "source_firebolt/source.py", "AirbyteSource/"), | ||
| ("establish_async_connection", "source_firebolt/source.py", "AirbyteSource/"), | ||
| ( | ||
| "establish_connection", | ||
| "destination_firebolt/destination.py", | ||
| "AirbyteDestination/", | ||
| ), | ||
| ( | ||
| "establish_async_connection", | ||
| "destination_firebolt/destination.py", | ||
| "AirbyteDestination/", | ||
| ), | ||
| ("get_conn", "firebolt_provider/hooks/firebolt.py", "Airflow/1.0.2"), | ||
| ("open", "dbt/adapters/firebolt/connections.py", "DBT/1.0.3"), | ||
| ], | ||
| ) | ||
| def test_usage_detection(function, path, expected, test_model): | ||
| test_path = TEST_FOLDER + path | ||
| create_test_file(test_model, function, test_path) | ||
| result = run( | ||
| ["python3", test_path], | ||
| stdout=PIPE, | ||
| stderr=PIPE, | ||
| env={"PYTHONPATH": os.getenv("PYTHONPATH", ""), "PATH": os.getenv("PATH", "")}, | ||
| ) | ||
| assert not result.stderr | ||
| assert expected in result.stdout.decode("utf-8") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.