vercel-internal-telemetry 0.7.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.
@@ -0,0 +1,130 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ # C extensions
6
+ *.so
7
+
8
+ # Distribution / packaging
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstallerhave
29
+ *.manifest
30
+ *.spec
31
+
32
+ # Installer logs
33
+ pip-log.txt
34
+ pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ .benchmarks/
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ .ruff_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Scrapy
57
+ .scrapy
58
+
59
+ # Sphinx documentation
60
+ docs/_build/
61
+
62
+ # PyBuilder
63
+ target/
64
+
65
+ # Jupyter Notebook
66
+ .ipynb_checkpoints
67
+
68
+ # IPython
69
+ profile_default/
70
+ ipython_config.py
71
+
72
+ # pyenv
73
+ .python-version
74
+
75
+ # pipenv
76
+ Pipfile.lock
77
+
78
+ # poetry
79
+ poetry.lock
80
+
81
+ # PDM
82
+ pdm.lock
83
+ .pdm.toml
84
+
85
+ # Hatch
86
+ .hatch/
87
+
88
+ # pyright/mypy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
92
+
93
+ # pyre
94
+ .pyre/
95
+
96
+ # pytype
97
+ .pytype/
98
+
99
+ # Caches
100
+ __pypackages__/
101
+
102
+ # Editor/project files
103
+ .DS_Store
104
+ .idea/
105
+ .vscode/
106
+ .zed/
107
+ *.swp
108
+ *.swo
109
+
110
+ # Virtual environments
111
+ .env
112
+ .venv
113
+ .venv.*
114
+ env/
115
+ venv/
116
+ **/.env
117
+ **/.venv
118
+ **/.venv.*
119
+ **/env/
120
+ **/venv/
121
+ ENV/
122
+ env.bak/
123
+ venv.bak/
124
+
125
+ # dotenv anywhere
126
+ **/.env
127
+ **/.env.*
128
+ **/*.env
129
+
130
+ .claude
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vercel, Inc.
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,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: vercel-internal-telemetry
3
+ Version: 0.7.0
4
+ Summary: Internal telemetry helpers for Vercel Python packages
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx<1,>=0.27.0
9
+ Requires-Dist: vercel-oidc>=0.7.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Internal Telemetry
13
+
14
+ `vercel.internal.telemetry` provides shared internal telemetry helpers for
15
+ Vercel Python packages.
@@ -0,0 +1,4 @@
1
+ # Internal Telemetry
2
+
3
+ `vercel.internal.telemetry` provides shared internal telemetry helpers for
4
+ Vercel Python packages.
@@ -0,0 +1,116 @@
1
+ """Hatch metadata hook for publish-time workspace dependency bounds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
10
+ from packaging.requirements import Requirement
11
+
12
+ try:
13
+ import tomllib
14
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
15
+ import tomli as tomllib # type: ignore[no-redef]
16
+
17
+
18
+ class WorkspaceDependenciesMetadataHook(MetadataHookInterface):
19
+ """Generate package dependencies from the repo-owned dependency table."""
20
+
21
+ def update(self, metadata: dict[str, Any]) -> None:
22
+ """Populate dynamic dependencies for Hatchling metadata generation."""
23
+ pyproject = _load_pyproject(Path(self.root))
24
+ release = pyproject.get("tool", {}).get("vercel", {}).get("release", {})
25
+ dependency_table = release.get("dependencies", {})
26
+ workspace_sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {})
27
+ workspace_names = {
28
+ name for name, source in workspace_sources.items() if source.get("workspace") is True
29
+ }
30
+ workspace_root = _find_workspace_root(Path(self.root))
31
+
32
+ metadata["dependencies"] = [
33
+ _rewrite_dependency(requirement, workspace_names, workspace_root)
34
+ for requirement in dependency_table.get("dependencies", [])
35
+ ]
36
+
37
+
38
+ def _load_pyproject(path: Path) -> dict[str, Any]:
39
+ with (path / "pyproject.toml").open("rb") as fp:
40
+ return tomllib.load(fp)
41
+
42
+
43
+ def _find_workspace_root(start: Path) -> Path | None:
44
+ for path in [start, *start.parents]:
45
+ pyproject = path / "pyproject.toml"
46
+ if not pyproject.exists():
47
+ continue
48
+ data = _load_pyproject(path)
49
+ if "workspace" in data.get("tool", {}).get("uv", {}):
50
+ return path
51
+ return None
52
+
53
+
54
+ def _rewrite_dependency(
55
+ requirement: str,
56
+ workspace_names: set[str],
57
+ workspace_root: Path | None,
58
+ ) -> str:
59
+ parsed = Requirement(requirement)
60
+ normalized = parsed.name.lower().replace("_", "-")
61
+ if normalized not in workspace_names or workspace_root is None:
62
+ return requirement
63
+ return _with_lower_bound(parsed, _read_workspace_version(workspace_root, normalized))
64
+
65
+
66
+ def _with_lower_bound(requirement: Requirement, version: str) -> str:
67
+ extras = f"[{','.join(sorted(requirement.extras))}]" if requirement.extras else ""
68
+ specifiers = [
69
+ str(specifier) for specifier in requirement.specifier if specifier.operator != ">="
70
+ ]
71
+ specifier_text = ",".join([f">={version}", *specifiers])
72
+ marker = f" ; {requirement.marker}" if requirement.marker else ""
73
+ return f"{requirement.name}{extras}{specifier_text}{marker}"
74
+
75
+
76
+ def _read_workspace_version(workspace_root: Path, package_name: str) -> str:
77
+ for pattern in ("src/*/pyproject.toml", "integrations/*/pyproject.toml"):
78
+ for pyproject_path in workspace_root.glob(pattern):
79
+ version = _version_from_pyproject(pyproject_path, package_name)
80
+ if version is not None:
81
+ return version
82
+ raise RuntimeError(f"unknown workspace dependency {package_name!r}")
83
+
84
+
85
+ def _version_from_pyproject(pyproject_path: Path, package_name: str) -> str | None:
86
+ data = _load_pyproject(pyproject_path.parent)
87
+ if data.get("project", {}).get("name") != package_name:
88
+ return None
89
+ version_path = pyproject_path.parent / data["tool"]["hatch"]["version"]["path"]
90
+ module = ast.parse(version_path.read_text(encoding="utf-8"), filename=str(version_path))
91
+ for node in module.body:
92
+ value = _version_value(node)
93
+ if value is None:
94
+ continue
95
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
96
+ return value.value
97
+ raise RuntimeError(f"could not find __version__ in {version_path}")
98
+
99
+
100
+ def _version_value(node: ast.stmt) -> ast.expr | None:
101
+ if isinstance(node, ast.Assign) and any(
102
+ isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets
103
+ ):
104
+ return node.value
105
+ if (
106
+ isinstance(node, ast.AnnAssign)
107
+ and isinstance(node.target, ast.Name)
108
+ and node.target.id == "__version__"
109
+ ):
110
+ return node.value
111
+ return None
112
+
113
+
114
+ def get_metadata_hook() -> type[MetadataHookInterface]:
115
+ """Return the hook class used by Hatchling's custom hook loader."""
116
+ return WorkspaceDependenciesMetadataHook
@@ -0,0 +1,29 @@
1
+ """Load the shared Vercel Hatch metadata hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.util import module_from_spec, spec_from_file_location
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
10
+
11
+
12
+ def get_metadata_hook() -> type[MetadataHookInterface]:
13
+ """Return the shared workspace dependency metadata hook."""
14
+ return _load_shared_hook().get_metadata_hook()
15
+
16
+
17
+ def _load_shared_hook() -> ModuleType:
18
+ root = Path(__file__).resolve().parent
19
+ candidates = [root / "../../scripts/hatch_build.py", root / "_vercel_hatch_build.py"]
20
+ for candidate in candidates:
21
+ path = candidate.resolve()
22
+ if path.exists():
23
+ spec = spec_from_file_location("_vercel_hatch_build", path)
24
+ if spec is None or spec.loader is None:
25
+ raise RuntimeError(f"could not load Hatch hook from {path}")
26
+ module = module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ return module
29
+ raise RuntimeError("could not find shared Vercel Hatch metadata hook")
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vercel-internal-telemetry"
7
+ dynamic = ["version", "dependencies"]
8
+ description = "Internal telemetry helpers for Vercel Python packages"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "LICENSE.*"]
13
+
14
+ [tool.hatch.metadata.hooks.custom]
15
+ path = "hatch_build.py"
16
+
17
+ [tool.vercel.release.dependencies]
18
+ dependencies = [
19
+ "httpx>=0.27.0,<1",
20
+ "vercel-oidc>=0.6.0",
21
+ ]
22
+
23
+ [tool.uv.sources]
24
+ vercel-oidc = { workspace = true }
25
+
26
+ [tool.hatch.version]
27
+ path = "vercel/internal/telemetry/version.py"
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ force-include = { "../../scripts/hatch_build.py" = "/_vercel_hatch_build.py" }
31
+ include = [
32
+ "/vercel/internal/telemetry/**/*.py",
33
+ "/vercel/internal/telemetry/py.typed",
34
+ "/README.md",
35
+ "/pyproject.toml",
36
+ "/hatch_build.py",
37
+ "/LICENSE",
38
+ ]
39
+ exclude = [
40
+ "/**/__pycache__",
41
+ ]
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ dev-mode-dirs = ["."]
45
+ only-include = [
46
+ "/vercel/internal/telemetry",
47
+ ]
48
+ exclude = [
49
+ "/**/__pycache__",
50
+ ]
51
+
52
+ [tool.poe]
53
+ include = "../../scripts/poe/poe.toml"
54
+ verbosity = -1
55
+
56
+ [tool.poe.tasks.typecheck]
57
+ cmd = "$MYPY"
58
+
59
+ [tool.poe.tasks.test]
60
+ cmd = "python -c \"pass\""
@@ -0,0 +1,6 @@
1
+ """Internal telemetry functionality for Vercel Python packages."""
2
+
3
+ from vercel.internal.telemetry.client import TelemetryClient
4
+ from vercel.internal.telemetry.tracker import track
5
+
6
+ __all__ = ["TelemetryClient", "track"]
@@ -0,0 +1,179 @@
1
+ """Telemetry client for tracking SDK usage."""
2
+
3
+ import atexit
4
+ import os
5
+ import time
6
+ import uuid
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from vercel.internal.telemetry.credentials import extract_credentials
12
+
13
+ _TELEMETRY_ENABLED = os.getenv("VERCEL_TELEMETRY_DISABLED") != "1"
14
+ _TELEMETRY_BRIDGE_URL = os.getenv(
15
+ "VERCEL_TELEMETRY_BRIDGE_URL",
16
+ "https://telemetry.vercel.com/api/vercel-py/v1/events",
17
+ )
18
+
19
+
20
+ class TelemetryClient:
21
+ """Client for sending telemetry events."""
22
+
23
+ def __init__(self, session_id: str | None = None):
24
+ """Initialize telemetry client.
25
+
26
+ Args:
27
+ session_id: Unique session ID. If not provided, generates a new one.
28
+ """
29
+ self.session_id = session_id or str(uuid.uuid4())
30
+ self._events: list[dict[str, Any]] = []
31
+ self._enabled = _TELEMETRY_ENABLED
32
+ # Register flush at exit so telemetry events are sent before program termination
33
+ atexit.register(self._flush_at_exit)
34
+
35
+ def track(
36
+ self,
37
+ event: str,
38
+ *,
39
+ user_id: str | None = None,
40
+ team_id: str | None = None,
41
+ project_id: str | None = None,
42
+ token: str | None = None,
43
+ **fields: Any,
44
+ ) -> None:
45
+ """
46
+ Track a generic telemetry event.
47
+
48
+ This is the single entry point for tracking all telemetry events.
49
+ Use the @telemetry decorator or track() function from tracker module
50
+ instead of calling this directly.
51
+
52
+ Args:
53
+ event: The event/action being tracked (e.g., 'blob_put', 'cache_get')
54
+ user_id: Optional user ID
55
+ team_id: Optional team ID
56
+ project_id: Optional project ID
57
+ token: Optional token to extract credentials from
58
+ **fields: Additional event fields (whitelisted by schema)
59
+ """
60
+ if not self._enabled:
61
+ return
62
+
63
+ # Extract credentials if not explicitly provided
64
+ extracted_user_id, extracted_team_id, extracted_project_id = extract_credentials(
65
+ token=token,
66
+ team_id=team_id,
67
+ project_id=project_id,
68
+ user_id=user_id,
69
+ )
70
+
71
+ # Use explicitly provided values, fall back to extracted
72
+ final_user_id = user_id or extracted_user_id
73
+ final_team_id = team_id or extracted_team_id
74
+ final_project_id = project_id or extracted_project_id
75
+
76
+ # Whitelist fields allowed by the generic schema for vercel_py
77
+ allowed_keys = {
78
+ "access",
79
+ "content_type",
80
+ "size_bytes",
81
+ "multipart",
82
+ "count",
83
+ "ttl_seconds",
84
+ "has_tags",
85
+ "hit",
86
+ "target",
87
+ "force_new",
88
+ }
89
+ event_fields: dict[str, Any] = {}
90
+ for k, v in fields.items():
91
+ if k in allowed_keys:
92
+ if isinstance(v, float) and v.is_integer():
93
+ event_fields[k] = int(v)
94
+ else:
95
+ event_fields[k] = v
96
+
97
+ event_data: dict[str, Any] = {
98
+ "id": str(uuid.uuid4()),
99
+ "event_time": int(time.time() * 1000),
100
+ "session_id": self.session_id,
101
+ "action": event,
102
+ }
103
+
104
+ if final_user_id:
105
+ event_data["user_id"] = final_user_id
106
+ if final_team_id:
107
+ event_data["team_id"] = final_team_id
108
+ if final_project_id:
109
+ event_data["project_id"] = final_project_id
110
+
111
+ # Merge whitelisted fields
112
+ event_data.update(event_fields)
113
+ self._events.append(event_data)
114
+
115
+ def flush(self) -> None:
116
+ """Flush all accumulated events to the telemetry bridge.
117
+
118
+ This is a synchronous method that can be safely called from atexit
119
+ handlers or from within existing event loops.
120
+ """
121
+ if not self._enabled or not self._events:
122
+ return
123
+
124
+ # Batch events by action type for efficient sending
125
+ batch: dict[str, list] = {}
126
+ for event in self._events:
127
+ action = event.get("action", "unknown")
128
+ if action not in batch:
129
+ batch[action] = []
130
+ batch[action].append(event)
131
+
132
+ if not batch:
133
+ return
134
+
135
+ try:
136
+ headers = {
137
+ "x-vercel-py-session-id": self.session_id,
138
+ "Content-Type": "application/json",
139
+ }
140
+
141
+ # Group all events under "generic" key since we're using generic schema
142
+ payload = {"generic": [event for events in batch.values() for event in events]}
143
+
144
+ with httpx.Client(timeout=30.0) as client:
145
+ response = client.post(
146
+ _TELEMETRY_BRIDGE_URL,
147
+ headers=headers,
148
+ json=payload,
149
+ )
150
+
151
+ if response.status_code == 204:
152
+ if _TELEMETRY_DEBUG():
153
+ print(f"Telemetry events tracked: {len(self._events)} events")
154
+ # Clear events only on successful delivery
155
+ self._events.clear()
156
+ else:
157
+ if _TELEMETRY_DEBUG():
158
+ print(f"Failed to send telemetry: {response.status_code}")
159
+
160
+ except Exception as e:
161
+ if _TELEMETRY_DEBUG():
162
+ print(f"Telemetry error: {e}")
163
+
164
+ def reset(self) -> None:
165
+ """Clear accumulated events."""
166
+ self._events.clear()
167
+
168
+ def _flush_at_exit(self) -> None:
169
+ """Flush events at program exit (called by atexit)."""
170
+ try:
171
+ self.flush()
172
+ except Exception:
173
+ # Silently fail - don't interrupt program exit
174
+ pass
175
+
176
+
177
+ def _TELEMETRY_DEBUG() -> bool:
178
+ """Check if telemetry debugging is enabled."""
179
+ return os.getenv("VERCEL_TELEMETRY_DEBUG") == "1"
@@ -0,0 +1,68 @@
1
+ """Utilities for extracting credentials (user_id, team_id, project_id) from various sources."""
2
+
3
+ import os
4
+
5
+ from vercel.oidc import decode_oidc_payload
6
+ from vercel.oidc.utils import find_project_info
7
+
8
+
9
+ def extract_credentials(
10
+ *,
11
+ token: str | None = None,
12
+ team_id: str | None = None,
13
+ project_id: str | None = None,
14
+ user_id: str | None = None,
15
+ ) -> tuple[str | None, str | None, str | None]:
16
+ """
17
+ Extract user_id, team_id, and project_id from various sources.
18
+
19
+ Priority order:
20
+ 1. Explicitly provided parameters
21
+ 2. Environment variables (VERCEL_PROJECT_ID, VERCEL_TEAM_ID)
22
+ 3. OIDC token payload (if available)
23
+ 4. .vercel/project.json (for local dev)
24
+
25
+ Returns:
26
+ Tuple of (user_id, team_id, project_id). Each may be None if not found.
27
+ """
28
+ # Start with explicitly provided values
29
+ resolved_user_id = user_id
30
+ resolved_team_id = team_id
31
+ resolved_project_id = project_id
32
+
33
+ # Check environment variables
34
+ if not resolved_project_id:
35
+ resolved_project_id = os.getenv("VERCEL_PROJECT_ID")
36
+ if not resolved_team_id:
37
+ resolved_team_id = os.getenv("VERCEL_TEAM_ID")
38
+
39
+ # Try to extract from OIDC token if available
40
+ if token:
41
+ try:
42
+ payload = decode_oidc_payload(token)
43
+ if not resolved_project_id:
44
+ resolved_project_id = payload.get("project_id")
45
+ if not resolved_team_id:
46
+ # OIDC tokens may have owner_id as team_id
47
+ resolved_team_id = payload.get("owner_id") or payload.get("team_id")
48
+ except Exception:
49
+ # Silently fail - OIDC may not be available or token may be invalid
50
+ pass
51
+
52
+ # Try to extract from .vercel/project.json for local dev
53
+ if not resolved_project_id or not resolved_team_id:
54
+ try:
55
+ project_info = find_project_info()
56
+ if not resolved_project_id and project_info.get("projectId"):
57
+ resolved_project_id = project_info["projectId"]
58
+ if not resolved_team_id and project_info.get("teamId"):
59
+ resolved_team_id = project_info["teamId"]
60
+ except Exception:
61
+ # Silently fail - .vercel directory may not exist (production context)
62
+ pass
63
+
64
+ # Note: user_id typically requires API call to determine from token
65
+ # For now, we leave it as None unless explicitly provided
66
+ # This is acceptable as user_id may not always be available in all contexts
67
+
68
+ return (resolved_user_id, resolved_team_id, resolved_project_id)
@@ -0,0 +1,292 @@
1
+ """Telemetry tracking helpers for SDK operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import threading
8
+ from collections.abc import Callable, Mapping, Sequence
9
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar
10
+
11
+ if TYPE_CHECKING:
12
+ from vercel.internal.telemetry.client import TelemetryClient
13
+
14
+ # Singleton telemetry client instance with thread-safe initialization
15
+ _telemetry_client = None
16
+ _telemetry_client_lock = threading.Lock()
17
+
18
+ T = TypeVar("T", bound=Callable[..., Any])
19
+
20
+
21
+ def get_client() -> TelemetryClient | None:
22
+ """Get or create the telemetry client singleton (thread-safe).
23
+
24
+ Returns:
25
+ TelemetryClient instance, or None if initialization fails.
26
+ """
27
+ global _telemetry_client
28
+ # Fast path without lock
29
+ client = _telemetry_client
30
+ if client is not None:
31
+ return client
32
+ # Slow path with double-checked locking
33
+ with _telemetry_client_lock:
34
+ client = _telemetry_client
35
+ if client is None:
36
+ try:
37
+ from vercel.internal.telemetry.client import TelemetryClient
38
+
39
+ _telemetry_client = TelemetryClient()
40
+ except Exception:
41
+ _telemetry_client = None
42
+ return _telemetry_client
43
+
44
+
45
+ def track(event: str, **attrs: Any) -> None:
46
+ """Track a telemetry event.
47
+
48
+ This is the main entry point for tracking telemetry events.
49
+ All attributes are passed through to the client's track method,
50
+ which handles credential extraction and field whitelisting.
51
+
52
+ Args:
53
+ event: The event/action being tracked (e.g., 'blob_put', 'cache_get')
54
+ **attrs: Additional event attributes (e.g., user_id, team_id, token, etc.)
55
+ """
56
+ client = get_client()
57
+ if client is None:
58
+ return
59
+ try:
60
+ client.track(event, **attrs)
61
+ except Exception:
62
+ # Silently fail - don't impact user's operation
63
+ pass
64
+
65
+
66
+ def with_telemetry(
67
+ action: str,
68
+ extract_metadata: Callable[..., dict[str, Any]] | None = None,
69
+ extract_token: Callable[..., str | None] | None = None,
70
+ extract_team_id: Callable[..., str | None] | None = None,
71
+ extract_project_id: Callable[..., str | None] | None = None,
72
+ ) -> Callable[[T], T]:
73
+ """
74
+ Create a decorator that automatically tracks telemetry for a function.
75
+
76
+ Usage:
77
+ @with_telemetry(
78
+ action="blob_put",
79
+ extract_metadata=lambda self, path, size=None: {"size": size}
80
+ )
81
+ def put(self, path, size=None):
82
+ ...
83
+
84
+ Args:
85
+ action: The action name to track
86
+ extract_metadata: Optional function to extract metadata from function call
87
+ extract_token: Optional function to extract token from function call
88
+ extract_team_id: Optional function to extract team_id from function call
89
+ extract_project_id: Optional function to extract project_id from function call
90
+
91
+ Returns:
92
+ Decorator function
93
+ """
94
+
95
+ def decorator(func: T) -> T:
96
+ @functools.wraps(func)
97
+ def sync_wrapper(*args, **kwargs):
98
+ # Execute the original function
99
+ result = func(*args, **kwargs)
100
+
101
+ # Extract metadata and credentials
102
+ metadata = None
103
+ token = None
104
+ team_id = None
105
+ project_id = None
106
+
107
+ if extract_metadata:
108
+ try:
109
+ metadata = extract_metadata(*args, **kwargs)
110
+ except Exception:
111
+ pass
112
+
113
+ if extract_token:
114
+ try:
115
+ token = extract_token(*args, **kwargs)
116
+ except Exception:
117
+ pass
118
+
119
+ if extract_team_id:
120
+ try:
121
+ team_id = extract_team_id(*args, **kwargs)
122
+ except Exception:
123
+ pass
124
+
125
+ if extract_project_id:
126
+ try:
127
+ project_id = extract_project_id(*args, **kwargs)
128
+ except Exception:
129
+ pass
130
+
131
+ # Track the event
132
+ track(
133
+ action,
134
+ token=token,
135
+ team_id=team_id,
136
+ project_id=project_id,
137
+ **(metadata or {}),
138
+ )
139
+
140
+ return result
141
+
142
+ @functools.wraps(func)
143
+ async def async_wrapper(*args, **kwargs):
144
+ # Execute the original function
145
+ result = await func(*args, **kwargs)
146
+
147
+ # Extract metadata and credentials (same as sync)
148
+ metadata = None
149
+ token = None
150
+ team_id = None
151
+ project_id = None
152
+
153
+ if extract_metadata:
154
+ try:
155
+ metadata = extract_metadata(*args, **kwargs)
156
+ except Exception:
157
+ pass
158
+
159
+ if extract_token:
160
+ try:
161
+ token = extract_token(*args, **kwargs)
162
+ except Exception:
163
+ pass
164
+
165
+ if extract_team_id:
166
+ try:
167
+ team_id = extract_team_id(*args, **kwargs)
168
+ except Exception:
169
+ pass
170
+
171
+ if extract_project_id:
172
+ try:
173
+ project_id = extract_project_id(*args, **kwargs)
174
+ except Exception:
175
+ pass
176
+
177
+ # Track the event
178
+ track(
179
+ action,
180
+ token=token,
181
+ team_id=team_id,
182
+ project_id=project_id,
183
+ **(metadata or {}),
184
+ )
185
+
186
+ return result
187
+
188
+ # Return appropriate wrapper based on whether function is async
189
+ import inspect
190
+
191
+ if inspect.iscoroutinefunction(func):
192
+ return async_wrapper # type: ignore
193
+ else:
194
+ return sync_wrapper # type: ignore
195
+
196
+ return decorator
197
+
198
+
199
+ def telemetry(
200
+ event: str,
201
+ capture: Sequence[str] | None = None,
202
+ derive: Mapping[str, Callable[[tuple, dict, Any], Any]] | None = None,
203
+ when: Literal["before", "after"] = "after",
204
+ ) -> Callable[[T], T]:
205
+ """Decorator to emit telemetry around a function call.
206
+
207
+ Args:
208
+ event: The event name to track
209
+ capture: List of parameter names to capture from args/kwargs.
210
+ Names are resolved against the function signature so positional calls
211
+ are handled correctly.
212
+ derive: Mapping of output field -> lambda(args, kwargs, result).
213
+ The callable receives (args, kwargs, result) and should return
214
+ the value for that field.
215
+ when: Emit "before" the call, or "after" the call (default: "after").
216
+
217
+ Returns:
218
+ Decorator function
219
+
220
+ Example:
221
+ @telemetry(
222
+ event="blob_delete",
223
+ capture=["token"],
224
+ derive={"count": lambda args, kwargs, rv: len(kwargs.get("urls", []))},
225
+ when="after",
226
+ )
227
+ def delete(urls: list[str], *, token: str | None = None) -> None:
228
+ ...
229
+ """
230
+
231
+ def decorator(func: T) -> T:
232
+ is_coro = inspect.iscoroutinefunction(func)
233
+ sig = inspect.signature(func)
234
+
235
+ def _emit(ev: str, args: tuple, kwargs: dict, result: Any) -> None:
236
+ try:
237
+ attrs: dict[str, Any] = {}
238
+ # Bind parameters for positional resolution
239
+ try:
240
+ bound = sig.bind_partial(*args, **kwargs)
241
+ params: dict[str, Any] = dict(bound.arguments) # name -> value
242
+ except Exception:
243
+ params = {}
244
+
245
+ # Capture selected params by name
246
+ if capture:
247
+ for name in capture:
248
+ if name in kwargs:
249
+ attrs[name] = kwargs[name]
250
+ elif name in params:
251
+ attrs[name] = params[name]
252
+ # else: silently skip if not provided
253
+
254
+ # Derived attributes
255
+ if derive:
256
+ for field, getter in derive.items():
257
+ try:
258
+ attrs[field] = getter(args, kwargs, result)
259
+ except Exception:
260
+ # ignore individual derivation errors
261
+ pass
262
+
263
+ track(ev, **attrs)
264
+ except Exception:
265
+ # Silently fail - don't impact user's operation
266
+ pass
267
+
268
+ @functools.wraps(func)
269
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
270
+ if when == "before":
271
+ _emit(event, args, kwargs, None)
272
+ result = await func(*args, **kwargs)
273
+ if when == "after":
274
+ _emit(event, args, kwargs, result)
275
+ return result
276
+
277
+ @functools.wraps(func)
278
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
279
+ if when == "before":
280
+ _emit(event, args, kwargs, None)
281
+ result = func(*args, **kwargs)
282
+ if when == "after":
283
+ _emit(event, args, kwargs, result)
284
+ return result
285
+
286
+ return async_wrapper if is_coro else sync_wrapper # type: ignore
287
+
288
+ return decorator
289
+
290
+
291
+ # Specific wrapper functions are intentionally removed;
292
+ # use generic `track(event, **attrs)` or the `telemetry` decorator instead.
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"