anyscale 0.26.32__py3-none-any.whl → 0.26.34__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.
Files changed (39) hide show
  1. anyscale/api.py +22 -0
  2. anyscale/aws_iam_policies.py +0 -3
  3. anyscale/client/README.md +20 -2
  4. anyscale/client/openapi_client/__init__.py +15 -1
  5. anyscale/client/openapi_client/api/default_api.py +625 -167
  6. anyscale/client/openapi_client/models/__init__.py +15 -1
  7. anyscale/client/openapi_client/models/cli_usage_payload.py +440 -0
  8. anyscale/client/openapi_client/models/cloud_deployment.py +31 -30
  9. anyscale/client/openapi_client/models/commit_ledger_item_type.py +111 -0
  10. anyscale/client/openapi_client/models/commit_ledger_record_v2.py +207 -0
  11. anyscale/client/openapi_client/models/complexity_level.py +101 -0
  12. anyscale/client/openapi_client/models/credit_grant_record_v2.py +181 -0
  13. anyscale/client/openapi_client/models/credit_ledger_item_type.py +104 -0
  14. anyscale/client/openapi_client/models/credit_ledger_record_v2.py +207 -0
  15. anyscale/client/openapi_client/models/credit_record_commit_v2.py +410 -0
  16. anyscale/client/openapi_client/models/credit_record_credit_v2.py +410 -0
  17. anyscale/client/openapi_client/models/credit_type.py +100 -0
  18. anyscale/client/openapi_client/models/credits_v2.py +355 -0
  19. anyscale/client/openapi_client/models/partition_info.py +152 -0
  20. anyscale/client/openapi_client/models/{pcp_config.py → summarize_machine_pool_request.py} +13 -12
  21. anyscale/client/openapi_client/models/summarize_machine_pool_response.py +181 -0
  22. anyscale/client/openapi_client/models/summarizemachinepoolresponse_response.py +121 -0
  23. anyscale/client/openapi_client/models/workspace_template.py +115 -3
  24. anyscale/client/openapi_client/models/workspace_template_readme.py +88 -3
  25. anyscale/commands/cloud_commands.py +12 -9
  26. anyscale/commands/command_examples.py +23 -6
  27. anyscale/commands/list_util.py +100 -38
  28. anyscale/integrations.py +0 -20
  29. anyscale/scripts.py +1 -0
  30. anyscale/shared_anyscale_utils/headers.py +4 -0
  31. anyscale/telemetry.py +424 -0
  32. anyscale/version.py +1 -1
  33. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/METADATA +1 -1
  34. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/RECORD +39 -24
  35. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/LICENSE +0 -0
  36. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/NOTICE +0 -0
  37. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/WHEEL +0 -0
  38. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/entry_points.txt +0 -0
  39. {anyscale-0.26.32.dist-info → anyscale-0.26.34.dist-info}/top_level.txt +0 -0
@@ -32,7 +32,47 @@ def _paginate(iterator: Iterator[Any], page_size: Optional[int]) -> Iterator[Lis
32
32
  yield page
33
33
 
34
34
 
35
- def display_list( # noqa: PLR0913
35
+ def _render_page(
36
+ page: List[Any],
37
+ item_formatter: Callable[[Any], Dict[str, Any]],
38
+ table_creator: Callable[[bool], Table],
39
+ json_output: bool,
40
+ is_first: bool,
41
+ page_num: int,
42
+ console: Console,
43
+ ) -> int:
44
+ """Render a single page of items."""
45
+ if page_num > 1: # Only show page number for pages after first
46
+ console.print(f"[dim]Page {page_num}[/dim]")
47
+
48
+ rows = [item_formatter(item) for item in page]
49
+ if json_output:
50
+ json_str = json_dumps(rows, indent=2, cls=AnyscaleJSONEncoder)
51
+ console.print_json(json=json_str)
52
+ else:
53
+ tbl = table_creator(is_first)
54
+ for row in rows:
55
+ tbl.add_row(*row.values())
56
+ console.print(tbl)
57
+
58
+ return len(page)
59
+
60
+
61
+ def _should_continue_pagination(
62
+ page_size: int, current_page_size: int, console: Console
63
+ ) -> bool:
64
+ """Prompt user to continue pagination if needed."""
65
+ if current_page_size < page_size:
66
+ return False # Last page, no need to prompt
67
+
68
+ console.print()
69
+ console.print(
70
+ "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
71
+ )
72
+ return input("> ").strip().lower() != "q"
73
+
74
+
75
+ def display_list( # noqa: PLR0913, PLR0912
36
76
  iterator: Iterator[Any],
37
77
  item_formatter: Callable[[Any], Dict[str, Any]],
38
78
  table_creator: Callable[[bool], Table],
@@ -65,56 +105,78 @@ def display_list( # noqa: PLR0913
65
105
  total_count = 0
66
106
  pages = _paginate(iterator, page_size if interactive else max_items)
67
107
 
68
- # fetch first page under spinner
108
+ # Start interactive session if needed
109
+ if interactive:
110
+ try:
111
+ from anyscale.telemetry import start_interactive_session
112
+
113
+ start_interactive_session()
114
+ except Exception: # noqa: BLE001
115
+ pass
116
+
117
+ # Fetch and render first page
69
118
  with console.status("Retrieving items…", spinner="dots"):
70
119
  try:
71
120
  first_page = next(pages)
72
121
  except StopIteration:
73
122
  first_page = []
74
123
 
75
- def _render(page: List[Any], is_first: bool, page_num: int):
76
- nonlocal total_count
77
- total_count += len(page)
78
- if interactive:
79
- console.print(f"[dim]Page {page_num}[/dim]")
80
- rows = [item_formatter(item) for item in page]
81
- if json_output:
82
- json_str = json_dumps(rows, indent=2, cls=AnyscaleJSONEncoder)
83
- console.print_json(json=json_str)
84
- else:
85
- tbl = table_creator(is_first)
86
- for row in rows:
87
- tbl.add_row(*row.values())
88
- console.print(tbl)
89
-
90
- # render first page
91
124
  if first_page:
92
- _render(first_page, True, page_num=1)
125
+ total_count += _render_page(
126
+ first_page, item_formatter, table_creator, json_output, True, 1, console
127
+ )
93
128
 
94
- # non-interactive: stop after first page
129
+ # For interactive commands, mark when command logic completes
130
+ if interactive:
131
+ try:
132
+ from anyscale.telemetry import mark_command_complete
133
+
134
+ mark_command_complete()
135
+ except Exception: # noqa: BLE001
136
+ pass
137
+
138
+ # Non-interactive: stop after first page
95
139
  if not interactive:
96
140
  return total_count
97
141
 
98
- # interactive: prompt after full first page
99
- if len(first_page) == page_size:
100
- console.print()
101
- console.print(
102
- "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
103
- )
104
- if input("> ").strip().lower() == "q":
105
- return total_count
142
+ # Interactive: check if user wants to continue
143
+ if not _should_continue_pagination(page_size, len(first_page), console):
144
+ return total_count
106
145
 
107
- # render remaining pages
146
+ # Render remaining pages with correct telemetry timing
108
147
  page_num = 2
109
- for page in pages:
110
- _render(page, False, page_num)
111
- if len(page) == page_size:
112
- console.print()
113
- console.print(
114
- "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
115
- )
116
- if input("> ").strip().lower() == "q":
117
- break
148
+ while True:
149
+ # Start page fetch timing and generate new trace ID BEFORE fetching
150
+ try:
151
+ from anyscale.telemetry import mark_page_fetch_start
152
+
153
+ mark_page_fetch_start(page_num)
154
+ except Exception: # noqa: BLE001
155
+ pass
156
+
157
+ # Now fetch the page (with the new trace ID)
158
+ try:
159
+ page = next(pages)
160
+ except StopIteration:
161
+ break
162
+
163
+ # Render the page
164
+ total_count += _render_page(
165
+ page, item_formatter, table_creator, json_output, False, page_num, console
166
+ )
167
+
168
+ # Complete page fetch telemetry
169
+ try:
170
+ from anyscale.telemetry import mark_page_fetch_complete
171
+
172
+ mark_page_fetch_complete(page_num)
173
+ except Exception: # noqa: BLE001
174
+ pass
175
+
176
+ # Check if user wants to continue or if this was the last page
177
+ if not _should_continue_pagination(page_size, len(page), console):
178
+ break
179
+
118
180
  page_num += 1
119
181
 
120
182
  return total_count
anyscale/integrations.py CHANGED
@@ -20,8 +20,6 @@ WANDB_API_KEY_NAME = "WANDB_API_KEY_NAME" # pragma: allowlist secret
20
20
  WANDB_PROJECT_NAME = "WANDB_PROJECT_NAME"
21
21
  WANDB_GROUP_NAME = "WANDB_GROUP_NAME"
22
22
 
23
- FLAG_WANDB_INTEGRATION_PROTOTYPE = "wandb-integration-prototype"
24
-
25
23
  log = BlockLogger() # Anyscale CLI Logger
26
24
 
27
25
 
@@ -131,13 +129,6 @@ def wandb_setup_api_key_hook() -> Optional[str]:
131
129
  be called by the OSS WandbLoggerCallback. Because this is called
132
130
  before wandb.init(), any other setup can also be done here.
133
131
  """
134
- api_client = get_auth_api_client(log_output=False).api_client
135
- feature_flag_on = api_client.check_is_feature_flag_on_api_v2_userinfo_check_is_feature_flag_on_get(
136
- FLAG_WANDB_INTEGRATION_PROTOTYPE
137
- ).result.is_on
138
- if not feature_flag_on:
139
- return None
140
-
141
132
  protected_api_key = wandb_get_api_key()
142
133
 
143
134
  try:
@@ -171,11 +162,6 @@ def set_wandb_project_group_env_vars():
171
162
  for production jobs, workspaces, and Ray jobs.
172
163
  """
173
164
  api_client = get_auth_api_client(log_output=False).api_client
174
- feature_flag_on = api_client.check_is_feature_flag_on_api_v2_userinfo_check_is_feature_flag_on_get(
175
- FLAG_WANDB_INTEGRATION_PROTOTYPE
176
- ).result.is_on
177
- if not feature_flag_on:
178
- return
179
165
 
180
166
  wandb_project_default = None
181
167
  wandb_group_default = None
@@ -224,12 +210,6 @@ def wandb_send_run_info_hook(run: Any) -> None:
224
210
  api_client = auth_api_client.api_client
225
211
  anyscale_api_client = auth_api_client.anyscale_api_client
226
212
 
227
- feature_flag_on = api_client.check_is_feature_flag_on_api_v2_userinfo_check_is_feature_flag_on_get(
228
- FLAG_WANDB_INTEGRATION_PROTOTYPE
229
- ).result.is_on
230
- if not feature_flag_on:
231
- return
232
-
233
213
  try:
234
214
  import wandb
235
215
  except ImportError:
anyscale/scripts.py CHANGED
@@ -43,6 +43,7 @@ from anyscale.commands.user_commands import user_cli
43
43
  from anyscale.commands.workspace_commands import workspace_cli
44
44
  from anyscale.commands.workspace_commands_v2 import workspace_cli as workspace_cli_v2
45
45
  import anyscale.conf
46
+ import anyscale.telemetry # IMPORTANT: auto-patches click instrumentation on import
46
47
  from anyscale.utils.cli_version_check_util import log_warning_if_version_needs_upgrade
47
48
 
48
49
 
@@ -25,6 +25,10 @@ class RequestHeaders(str, Enum):
25
25
  # Identifies the source of the client
26
26
  SOURCE = "X-Anyscale-Source"
27
27
 
28
+ # W3C Trace Context traceparent header
29
+ # https://www.w3.org/TR/trace-context/#traceparent-header
30
+ TRACEPARENT = "traceparent"
31
+
28
32
 
29
33
  class ResponseHeaders(str, Enum):
30
34
  """
anyscale/telemetry.py ADDED
@@ -0,0 +1,424 @@
1
+ """
2
+ Telemetry for Anyscale CLI commands.
3
+
4
+ Patches Click to capture execution metrics for _leaf_ commands,
5
+ including command path, flags, timing, and errors. Emits via
6
+ HTTP POST (best-effort) or debug print.
7
+
8
+ Supports session-based distributed tracing for interactive commands:
9
+ - Each command gets a unique trace_id for backend correlation
10
+ - Interactive sessions get a session_id to group related operations
11
+ - Page fetches get new trace_ids but share the session_id
12
+ """
13
+
14
+ from contextvars import ContextVar
15
+ import functools
16
+ import json
17
+ import os
18
+ import random
19
+ import secrets
20
+ import sys
21
+ import threading
22
+ import time
23
+ from typing import List, Optional
24
+
25
+ import click
26
+
27
+ from anyscale.cli_logger import BlockLogger
28
+ from anyscale.client.openapi_client.models.cli_usage_payload import CLIUsagePayload
29
+
30
+
31
+ # ─── Configuration ────────────────────────────────────────────────────────────
32
+
33
+ SAMPLE_RATE = float(os.getenv("ANYSCALE_TELEMETRY_SAMPLE_RATE", "1.0"))
34
+ TELEMETRY_DEBUG = os.getenv("ANYSCALE_DEBUG") == "1"
35
+
36
+ # ContextVar automatically propagates into asyncio tasks if you ever go async.
37
+ # (Each CLI invocation gets its own interpreter, so this never crosses commands.)
38
+ _trace_id_var: ContextVar[Optional[str]] = ContextVar("_trace_id_var", default=None)
39
+ _session_id_var: ContextVar[Optional[str]] = ContextVar("_session_id_var", default=None)
40
+ _skip_click_patch_var: ContextVar[bool] = ContextVar(
41
+ "_skip_click_patch_var", default=False
42
+ )
43
+
44
+ logger = BlockLogger()
45
+
46
+ # ─── Trace Context Helpers ───────────────────────────────────────────────────
47
+
48
+
49
+ def _setup_trace_context() -> str:
50
+ """Ensure we have a trace ID in the ContextVar, and return it."""
51
+ try:
52
+ tid = _trace_id_var.get()
53
+ if tid is None:
54
+ tid = secrets.token_hex(16)
55
+ _trace_id_var.set(tid)
56
+ logger.debug(f"[TRACE DEBUG] trace-id={tid}")
57
+ return tid
58
+ except Exception: # noqa: BLE001
59
+ # Fallback to a default trace ID if anything goes wrong
60
+ return secrets.token_hex(16)
61
+
62
+
63
+ def get_traceparent() -> Optional[str]:
64
+ """Return a W3C-style traceparent header, or None if not initialized."""
65
+ try:
66
+ tid = _trace_id_var.get()
67
+ if not tid:
68
+ return None
69
+ return f"00-{tid}-{'0'*16}-01"
70
+ except Exception: # noqa: BLE001
71
+ return None
72
+
73
+
74
+ def start_interactive_session() -> str:
75
+ """Start an interactive session and return the session ID."""
76
+ try:
77
+ session_id = secrets.token_hex(8)
78
+ _session_id_var.set(session_id)
79
+ logger.debug(f"[TRACE DEBUG] session-id={session_id}")
80
+ return session_id
81
+ except Exception: # noqa: BLE001
82
+ # Return a fallback session ID
83
+ return secrets.token_hex(8)
84
+
85
+
86
+ def new_trace_for_page() -> str:
87
+ """Generate a new trace ID for the next page in an interactive session."""
88
+ try:
89
+ new_trace_id = secrets.token_hex(16)
90
+ _trace_id_var.set(new_trace_id)
91
+ logger.debug(f"[TRACE DEBUG] new-trace-id={new_trace_id}")
92
+ return new_trace_id
93
+ except Exception: # noqa: BLE001
94
+ # Return a fallback trace ID
95
+ return secrets.token_hex(16)
96
+
97
+
98
+ # ─── CLI Arg Extraction ───────────────────────────────────────────────────────
99
+
100
+
101
+ def _get_user_flags() -> List[str]:
102
+ """Return all `-x`/`--long` flags from the raw argv (no values)."""
103
+ try:
104
+ args = sys.argv[1:]
105
+ # Strip off the program name if Click added it
106
+ if args and args[0] in ("anyscale", "main"):
107
+ args = args[1:]
108
+ return [a for a in args if a.startswith("-")]
109
+ except Exception: # noqa: BLE001
110
+ return []
111
+
112
+
113
+ def _get_user_options(ctx: click.Context) -> List[str]:
114
+ """Return the names of parameters explicitly set via the CLI."""
115
+ try:
116
+ opts: List[str] = []
117
+ for name in ctx.params:
118
+ try:
119
+ if (
120
+ ctx.get_parameter_source(name)
121
+ is click.core.ParameterSource.COMMANDLINE
122
+ ):
123
+ opts.append(name)
124
+ except Exception: # noqa: BLE001
125
+ opts.append(name)
126
+ return opts
127
+ except Exception: # noqa: BLE001
128
+ return []
129
+
130
+
131
+ # ─── Page Fetch Tracking ─────────────────────────────────────────────────────
132
+
133
+ _page_fetch_start_time: ContextVar[Optional[float]] = ContextVar(
134
+ "_page_fetch_start_time", default=None
135
+ )
136
+
137
+
138
+ def mark_page_fetch_start(page_number: int) -> None:
139
+ """
140
+ Mark the start of a page fetch operation. This will:
141
+ 1. Generate a new trace ID for this page
142
+ 2. Start timing the fetch operation
143
+
144
+ Args:
145
+ page_number: The page number being fetched (1-indexed)
146
+ """
147
+ try:
148
+ if SAMPLE_RATE <= 0 or random.random() > SAMPLE_RATE:
149
+ return
150
+
151
+ # Generate new trace ID for this page BEFORE making the API request
152
+ new_trace_for_page()
153
+
154
+ # Start timing
155
+ _page_fetch_start_time.set(time.perf_counter())
156
+
157
+ logger.debug(f"[TRACE DEBUG] page-fetch-start page={page_number}")
158
+ except Exception: # noqa: BLE001
159
+ # Telemetry should never crash the CLI
160
+ pass
161
+
162
+
163
+ def mark_page_fetch_complete(page_number: int) -> None:
164
+ """
165
+ Mark the completion of a page fetch operation and emit telemetry.
166
+ This calculates the duration and sends the page_fetch event.
167
+
168
+ Args:
169
+ page_number: The page number that was fetched (1-indexed)
170
+ """
171
+ try:
172
+ if SAMPLE_RATE <= 0 or random.random() > SAMPLE_RATE:
173
+ return
174
+
175
+ # Calculate duration
176
+ start_time = _page_fetch_start_time.get()
177
+ if start_time is None:
178
+ # Fallback if timing wasn't started properly
179
+ duration_ms = 0.0
180
+ else:
181
+ duration_ms = (time.perf_counter() - start_time) * 1000
182
+
183
+ # Get current click context
184
+ try:
185
+ ctx = click.get_current_context()
186
+ except RuntimeError:
187
+ return # No active context
188
+
189
+ # Get current trace ID (should be the one we generated in mark_page_fetch_start)
190
+ trace_id = _trace_id_var.get()
191
+ if not trace_id:
192
+ return
193
+
194
+ # Emit page fetch telemetry
195
+ body = _create_payload(
196
+ trace_id=trace_id,
197
+ ctx=ctx,
198
+ duration_ms=duration_ms,
199
+ exit_code=0,
200
+ exception_type=None,
201
+ event_type="page_fetch",
202
+ page_number=page_number,
203
+ )
204
+ _emit_telemetry(body)
205
+
206
+ # Reset timing
207
+ _page_fetch_start_time.set(None)
208
+
209
+ logger.debug(
210
+ f"[TRACE DEBUG] page-fetch-complete page={page_number} duration={duration_ms:.2f}ms"
211
+ )
212
+ except Exception: # noqa: BLE001
213
+ # Telemetry should never crash the CLI
214
+ pass
215
+
216
+
217
+ # ─── Payload Construction ────────────────────────────────────────────────────
218
+
219
+
220
+ def _create_payload(
221
+ trace_id: str,
222
+ ctx: click.Context,
223
+ duration_ms: float,
224
+ exit_code: int,
225
+ exception_type: Optional[str],
226
+ event_type: str = "command",
227
+ page_number: Optional[int] = None,
228
+ ) -> CLIUsagePayload:
229
+ """
230
+ Build a Typed CLIUsagePayload (from the generated OpenAPI models)
231
+ so we get IDE/type-checker support on all the fields.
232
+
233
+ Args:
234
+ trace_id: Unique trace identifier for this operation
235
+ ctx: Click context containing command information
236
+ duration_ms: Command/operation duration in milliseconds
237
+ exit_code: Command exit code (0 for success, 1 for error)
238
+ exception_type: Exception class name if command failed
239
+ event_type: Type of event ("command" or "page_fetch")
240
+ page_number: Page number for page_fetch events
241
+ """
242
+ try:
243
+ # Get session ID if available
244
+ session_id = _session_id_var.get()
245
+
246
+ data = {
247
+ "trace_id": trace_id,
248
+ "session_id": session_id,
249
+ "event_type": event_type,
250
+ "page_number": page_number,
251
+ "cmd_path": ctx.command_path,
252
+ "options": sorted(_get_user_options(ctx)),
253
+ "flags_used": sorted(_get_user_flags()),
254
+ "duration_ms": round(duration_ms, 2),
255
+ "exit_code": exit_code,
256
+ "exception_type": exception_type,
257
+ "cli_version": getattr(sys.modules.get("anyscale"), "__version__", None),
258
+ "python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
259
+ "timestamp": int(time.time()),
260
+ }
261
+ return CLIUsagePayload(**data)
262
+ except Exception: # noqa: BLE001
263
+ # Fallback payload with minimal data if construction fails
264
+ fallback_data = {
265
+ "trace_id": trace_id,
266
+ "cmd_path": "unknown",
267
+ "duration_ms": duration_ms,
268
+ "exit_code": exit_code,
269
+ "timestamp": int(time.time()),
270
+ }
271
+ return CLIUsagePayload(**fallback_data)
272
+
273
+
274
+ def mark_command_complete() -> None:
275
+ """
276
+ Mark that the command logic has completed and emit telemetry immediately.
277
+ For interactive commands, call this when data is ready but before user interaction.
278
+ This will prevent the Click patch from double-emitting.
279
+ """
280
+ try:
281
+ trace_id = _trace_id_var.get()
282
+ if not trace_id:
283
+ return
284
+
285
+ # Get current click context
286
+ try:
287
+ ctx = click.get_current_context()
288
+ except RuntimeError:
289
+ return # No active context
290
+
291
+ # Calculate duration from the click context if available
292
+ # For interactive commands, we want the time up to this point
293
+ start_time = getattr(ctx, "telemetry_start_time", None)
294
+ if start_time is None:
295
+ # Fallback: use a minimal duration
296
+ duration_ms = 0.0
297
+ else:
298
+ duration_ms = (time.perf_counter() - start_time) * 1000
299
+
300
+ # Emit the command completion event
301
+ body = _create_payload(
302
+ trace_id=trace_id,
303
+ ctx=ctx,
304
+ duration_ms=duration_ms,
305
+ exit_code=0,
306
+ exception_type=None,
307
+ event_type="command",
308
+ )
309
+ _emit_telemetry(body)
310
+
311
+ # Prevent Click patch from emitting again
312
+ _skip_click_patch_var.set(True)
313
+ except Exception: # noqa: BLE001
314
+ # Telemetry should never crash the CLI
315
+ pass
316
+
317
+
318
+ # ─── Emission (fire-&-forget) ─────────────────────────────────────────────────
319
+
320
+
321
+ def _emit_telemetry(body: CLIUsagePayload) -> None:
322
+ """
323
+ Send the payload to the console API. Runs in a short-lived thread
324
+ so we never block the CLI for more than ~3 seconds.
325
+ """
326
+ try:
327
+ logger.debug(json.dumps(body.to_dict(), indent=2))
328
+
329
+ def _worker():
330
+ try:
331
+ # Lazy imports to avoid circular deps
332
+ from anyscale.authenticate import get_auth_api_client
333
+ from anyscale.client.openapi_client.api.default_api import DefaultApi
334
+
335
+ auth_block = get_auth_api_client()
336
+ api = DefaultApi(api_client=auth_block.anyscale_api_client)
337
+ api.receive_cli_usage_api_v2_cli_usage_post(
338
+ cli_usage_payload=body, _request_timeout=2
339
+ )
340
+ except Exception: # noqa: BLE001
341
+ # Best-effort only - never crash the CLI
342
+ pass
343
+
344
+ thread = threading.Thread(target=_worker, daemon=False)
345
+ thread.start()
346
+ thread.join(timeout=3)
347
+ except Exception: # noqa: BLE001
348
+ # Telemetry should never crash the CLI
349
+ pass
350
+
351
+
352
+ # ─── Click Patch ─────────────────────────────────────────────────────────────
353
+
354
+
355
+ def _patch_click() -> None:
356
+ """Monkey-patch Click so that each leaf command emits telemetry."""
357
+ try:
358
+ if getattr(click, "_anyscale_telemetry_patched", False):
359
+ return
360
+
361
+ original_invoke = click.Command.invoke
362
+
363
+ @functools.wraps(original_invoke)
364
+ def instrumented_invoke(self, ctx, *args, **kwargs):
365
+ try:
366
+ # Sampling
367
+ if SAMPLE_RATE <= 0 or random.random() > SAMPLE_RATE:
368
+ return original_invoke(self, ctx, *args, **kwargs)
369
+ # Only instrument leaf commands
370
+ if isinstance(self, click.Group):
371
+ return original_invoke(self, ctx, *args, **kwargs)
372
+
373
+ trace_id = _setup_trace_context()
374
+ start = time.perf_counter()
375
+
376
+ # Store start time in context for interactive commands
377
+ ctx.telemetry_start_time = start
378
+
379
+ code, exc = 0, None
380
+ should_emit_telemetry = True
381
+
382
+ try:
383
+ result = original_invoke(self, ctx, *args, **kwargs)
384
+ return result
385
+ except Exception as e:
386
+ code, exc = 1, e.__class__.__name__
387
+ raise
388
+ finally:
389
+ # Only emit telemetry once per command invocation
390
+ if _skip_click_patch_var.get():
391
+ should_emit_telemetry = False
392
+
393
+ if should_emit_telemetry:
394
+ try:
395
+ # Use actual end time for non-interactive commands
396
+ dur = (time.perf_counter() - start) * 1_000
397
+ body = _create_payload(
398
+ trace_id=trace_id,
399
+ ctx=ctx,
400
+ duration_ms=dur,
401
+ exit_code=code,
402
+ exception_type=exc,
403
+ event_type="command",
404
+ )
405
+ _emit_telemetry(body)
406
+ _skip_click_patch_var.set(True)
407
+ except Exception: # noqa: BLE001
408
+ # Telemetry should never crash the CLI
409
+ pass
410
+ except Exception: # noqa: BLE001
411
+ # If telemetry setup fails, just run the original command
412
+ return original_invoke(self, ctx, *args, **kwargs)
413
+
414
+ click.Command.invoke = instrumented_invoke
415
+ click._anyscale_telemetry_patched = ( # noqa: SLF001 # type: ignore[attr-defined]
416
+ True
417
+ )
418
+ except Exception: # noqa: BLE001
419
+ # If patching fails, telemetry just won't work - don't crash the CLI
420
+ pass
421
+
422
+
423
+ # Auto-patch on import
424
+ _patch_click()
anyscale/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.26.32"
1
+ __version__ = "0.26.34"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: anyscale
3
- Version: 0.26.32
3
+ Version: 0.26.34
4
4
  Summary: Command Line Interface for Anyscale
5
5
  Author: Anyscale Inc.
6
6
  License: AS License