vercel-internal-telemetry 0.7.0__py3-none-any.whl

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,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)
File without changes
@@ -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"
@@ -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,10 @@
1
+ vercel/internal/telemetry/__init__.py,sha256=wlso1BcdmaRrpwKGGbgNyh-zC8xA9g4i7YQNeoeudZA,221
2
+ vercel/internal/telemetry/client.py,sha256=pWzjtSDEAuU7Dd2EQjzRaT-k6Al-pgz_RZsInlLkfIY,5799
3
+ vercel/internal/telemetry/credentials.py,sha256=oOv1yPfdxa4MkwC5tnscNN0cdY3_VI907Kmtcsec0Uk,2540
4
+ vercel/internal/telemetry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ vercel/internal/telemetry/tracker.py,sha256=ARB_lbKiXZeDTl4cdm1erbpYyuevpl6gUFBkZVXn5Sc,9326
6
+ vercel/internal/telemetry/version.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
7
+ vercel_internal_telemetry-0.7.0.dist-info/METADATA,sha256=OPk4eHpIf7NT0ZJR3YRIJkA8jUpkbQ4qx70GL_o5RX0,430
8
+ vercel_internal_telemetry-0.7.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ vercel_internal_telemetry-0.7.0.dist-info/licenses/LICENSE,sha256=ZhFC5TwxPSu14bBV9cCjkAFFD_G14nuJ3EvH3ppjUso,1069
10
+ vercel_internal_telemetry-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.