coder-eval 0.8.2__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 (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,407 @@
1
+ """User telemetry via OpenTelemetry → Azure Application Insights ``customEvents``.
2
+
3
+ A self-contained, opt-out usage-telemetry side-channel. It emits discrete
4
+ lifecycle events (run-start, task-end, per-command) to the App Insights
5
+ ``customEvents`` table and is **never** part of the eval data path.
6
+
7
+ How customEvents routing works
8
+ ------------------------------
9
+ The Azure Monitor exporter routes an OpenTelemetry *log record* to the
10
+ ``customEvents`` table (instead of the default ``traces`` table) **iff** the
11
+ record carries the attribute ``microsoft.custom_event.name``. The event name is
12
+ that attribute's value; every other record attribute becomes a
13
+ ``customDimensions`` entry. We reach that attribute through plain stdlib
14
+ logging: an OTel ``LoggingHandler`` is attached to a dedicated logger, and
15
+ ``track_event`` calls ``logger.info(name, extra={...})``. So the only OTel/Azure
16
+ imports live inside ``init_telemetry`` — ``track_event`` is pure stdlib and a
17
+ cheap no-op when telemetry is off.
18
+
19
+ Posture
20
+ -------
21
+ Telemetry is **on by default**: an ingestion-only Application Insights connection
22
+ string is baked into the app (``config._DEFAULT_TELEMETRY_CONNECTION_STRING``) so a
23
+ fresh install reports usage to the shared coder-eval resource. An explicitly-set
24
+ ``APPLICATIONINSIGHTS_CONNECTION_STRING`` / ``UIPATH_AI_CONNECTION_STRING`` /
25
+ ``TELEMETRY_CONNECTION_STRING`` (env or ``.env``) takes precedence, routing
26
+ telemetry elsewhere. Telemetry is **off** only when ``TELEMETRY_ENABLED`` is set
27
+ false (the single canonical disable gate) or the connection string is cleared.
28
+ No prompts, file contents, or repo paths are ever captured — only enums, counts,
29
+ durations, an anonymous per-install id (a random UUID persisted in the user
30
+ config file — identifies an install, not a person), and non-PII platform
31
+ identity (OS / arch / Python version). Because telemetry is default-on, the first
32
+ run that initializes it prints a one-time stderr notice disclosing what is
33
+ collected and how to disable it (``_maybe_show_first_run_notice``).
34
+
35
+ Non-fatal contract
36
+ -------------------
37
+ Every public function wraps its body in ``try/except Exception`` and logs a
38
+ warning rather than raising — telemetry must never break a run. This invariant
39
+ is enforced by the CE019 custom lint rule. Persisting the anonymous install id
40
+ is best-effort too: if its config file can't be written, telemetry still emits
41
+ events, just without the ``InstallId`` dimension.
42
+ """
43
+
44
+ import atexit
45
+ import hashlib
46
+ import json
47
+ import logging
48
+ import os
49
+ import platform
50
+ import sys
51
+ import time
52
+ import uuid
53
+ from collections.abc import Callable
54
+ from pathlib import Path
55
+ from typing import Any, TypeVar
56
+
57
+ import typer
58
+
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ # Dedicated stdlib logger that fans events into the OTel handler. Stays None
63
+ # until init_telemetry wires up a provider; track_event no-ops while None.
64
+ _events_logger: logging.Logger | None = None
65
+ # The OTel LoggerProvider (typed Any: OTel/Azure are largely untyped and we keep
66
+ # their symbols confined to init_telemetry to avoid leaking Unknown elsewhere).
67
+ _provider: Any = None
68
+ # The OTel handler attached to the dedicated events logger; tracked so
69
+ # shutdown_telemetry can detach it (the logger is a process-wide singleton that
70
+ # outlives a shutdown, so leaving it attached would double-emit on a re-init).
71
+ _handler: logging.Handler | None = None
72
+ # Enrichment merged into every event — all scalar, no user content. The
73
+ # per-process session id lives here under "SessionId" (no separate global).
74
+ _default_props: dict[str, str] = {}
75
+ _initialized: bool = False
76
+
77
+ # stdlib LogRecord reserved attribute names. A property key colliding with one
78
+ # of these makes logging raise KeyError on emit, so the coercer drops them.
79
+ _RESERVED_LOGRECORD_ATTRS: frozenset[str] = frozenset(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
80
+
81
+ # The attribute the Azure Monitor exporter looks for to route a log record to
82
+ # the customEvents table. Hard-coded (matches the exporter's internal
83
+ # _MICROSOFT_CUSTOM_EVENT_NAME constant) so track_event needs no OTel import.
84
+ _CUSTOM_EVENT_NAME_ATTR = "microsoft.custom_event.name"
85
+
86
+ # Version of the event property contract. Stamped as the `SchemaVersion`
87
+ # dimension on every event so the dashboard's Kusto queries (a cross-system
88
+ # contract) can detect a schema change instead of silently breaking. Bump on any
89
+ # breaking property rename/removal.
90
+ _TELEMETRY_SCHEMA_VERSION = "1"
91
+
92
+ # One-time stderr notice shown on the first run that telemetry is on (default-on
93
+ # tooling must disclose collection). Persisted-once via a flag in the user config
94
+ # file; see _maybe_show_first_run_notice.
95
+ _FIRST_RUN_NOTICE = (
96
+ "coder-eval collects anonymous usage telemetry (command names, outcomes, counts, durations, "
97
+ "an anonymous per-install id, and platform info — never prompts, file contents, or repo paths) "
98
+ "to help improve the tool.\n"
99
+ "It is on by default. Disable it any time with TELEMETRY_ENABLED=false."
100
+ )
101
+
102
+ # The scalar contract for event properties. Public so producers (e.g.
103
+ # orchestrator.build_task_event) can annotate their event dicts with it and have
104
+ # pyright reject a non-scalar at the producing call site, not just at runtime.
105
+ Scalar = str | int | float | bool
106
+
107
+ F = TypeVar("F", bound=Callable[..., Any])
108
+
109
+
110
+ def hash_identifier(value: str) -> str:
111
+ """Stable, one-way hash of a free-text identifier for telemetry.
112
+
113
+ ``TaskId`` / ``VariantId`` are author-defined free-text that could encode
114
+ sensitive data, so they are emitted as a truncated SHA-256 hex digest rather
115
+ than verbatim — the raw string never reaches the telemetry store. The digest
116
+ is deterministic across runs/processes/platforms (unlike the salted builtin
117
+ ``hash()``), so dashboards can still group by a stable key, and is the same
118
+ across installs so a task can be sliced fleet-wide. Empty input maps to empty
119
+ output so a missing variant stays distinguishable from a present one.
120
+ """
121
+ if not value:
122
+ return ""
123
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
124
+
125
+
126
+ def _coerce_props(properties: dict[str, Any] | None) -> dict[str, Scalar]:
127
+ """Coerce a property map into logging-safe scalars.
128
+
129
+ Drops ``None`` values and keys that collide with reserved ``LogRecord``
130
+ attribute names; ``str()``-ifies any non-scalar value so no nested
131
+ structure reaches the exporter.
132
+ """
133
+ coerced: dict[str, Scalar] = {}
134
+ for key, value in (properties or {}).items():
135
+ if value is None or key in _RESERVED_LOGRECORD_ATTRS:
136
+ continue
137
+ coerced[key] = value if isinstance(value, str | int | float | bool) else str(value)
138
+ return coerced
139
+
140
+
141
+ def _config_path() -> Path:
142
+ """Path to the user config file holding the stable anonymous install id.
143
+
144
+ ``$XDG_CONFIG_HOME/coder-eval/config.json`` (``~/.config/coder-eval/config.json``
145
+ by default), matching the XDG base-directory convention on every platform.
146
+ """
147
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
148
+ return Path(base) / "coder-eval" / "config.json"
149
+
150
+
151
+ def _get_or_create_install_id() -> str | None:
152
+ """Return a stable, anonymous per-install id (no PII), or None if unpersistable.
153
+
154
+ Persisted as ``install_id`` in the user config file, generated once on first
155
+ run and reused thereafter so the same machine/install reads as one "user".
156
+ The value is a random UUID — it identifies an install, not a person.
157
+
158
+ Best-effort: if the id can't be resolved or persisted for ANY reason (read-only
159
+ home, no HOME so ``Path.home()`` raises, corrupt state, …) this returns None and
160
+ telemetry simply emits events without the ``InstallId`` dimension — it never
161
+ raises, because telemetry must never break a run.
162
+ """
163
+ try:
164
+ path = _config_path()
165
+ try:
166
+ data = json.loads(path.read_text(encoding="utf-8"))
167
+ if not isinstance(data, dict):
168
+ data = {}
169
+ except (OSError, ValueError):
170
+ # File missing/unreadable/corrupt → start fresh and try to write below.
171
+ data = {}
172
+
173
+ existing = data.get("install_id")
174
+ if isinstance(existing, str) and existing:
175
+ return existing
176
+
177
+ install_id = str(uuid.uuid4())
178
+ data["install_id"] = install_id
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
181
+ return install_id
182
+ except Exception as exc:
183
+ # Never propagate: a missing HOME (Path.home() → RuntimeError), an
184
+ # unwritable dir (OSError), etc. must degrade to no InstallId, NOT disable
185
+ # telemetry. Keeping telemetry live without InstallId is the agreed behavior.
186
+ logger.debug("could not persist install id (%s); telemetry will omit InstallId", exc)
187
+ return None
188
+
189
+
190
+ def _maybe_show_first_run_notice() -> None:
191
+ """Print a one-time stderr notice that telemetry is on (best-effort).
192
+
193
+ Shown once per install: a ``telemetry_notice_shown`` flag is persisted in the
194
+ same user config file as the install id (``_config_path``). Default-on tooling
195
+ must disclose that it is collecting — what is gathered, that it is anonymous,
196
+ and how to turn it off — rather than collect silently.
197
+
198
+ Best-effort and never raises: if the flag can't be persisted the notice may
199
+ show again on a later run (over-notifying beats silent collection), and any
200
+ failure degrades to no notice — telemetry must never break a run.
201
+ """
202
+ try:
203
+ path = _config_path()
204
+ try:
205
+ data = json.loads(path.read_text(encoding="utf-8"))
206
+ if not isinstance(data, dict):
207
+ data = {}
208
+ except (OSError, ValueError):
209
+ data = {}
210
+
211
+ if data.get("telemetry_notice_shown"):
212
+ return
213
+
214
+ print(_FIRST_RUN_NOTICE, file=sys.stderr)
215
+
216
+ data["telemetry_notice_shown"] = True
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
219
+ except Exception as exc:
220
+ # Never propagate: an unwritable config dir, missing HOME, etc. must
221
+ # degrade to no recorded notice, NOT break the run.
222
+ logger.debug("could not record first-run telemetry notice (%s)", exc)
223
+
224
+
225
+ def init_telemetry(version: str) -> None:
226
+ """Initialize telemetry once per process (no-op if disabled or already done).
227
+
228
+ Fully non-fatal: if the install-id config file can't be written, telemetry
229
+ still initializes and emits events — just without the ``InstallId`` dimension
230
+ — and never crashes the process.
231
+
232
+ Args:
233
+ version: The coder-eval version, recorded as the ``Version`` dimension.
234
+ """
235
+ global _events_logger, _provider, _handler, _default_props, _initialized
236
+ try:
237
+ if _initialized:
238
+ return
239
+
240
+ # Single-init contract: settings is read once per process here. A
241
+ # shutdown → re-init cycle re-reads the same module-global settings
242
+ # singleton (only tests, which monkeypatch settings, exercise re-init).
243
+ from coder_eval.config import settings
244
+
245
+ if not settings.telemetry_enabled or not settings.telemetry_connection_string:
246
+ return
247
+
248
+ try:
249
+ import warnings
250
+
251
+ from azure.monitor.opentelemetry.exporter import AzureMonitorLogExporter
252
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
253
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
254
+ from opentelemetry.sdk.resources import Resource
255
+ except ImportError:
256
+ logger.debug("telemetry SDK unavailable; disabled")
257
+ return
258
+
259
+ # Construct the exporter in its own guard: it parses the connection
260
+ # string (a credential — InstrumentationKey + IngestionEndpoint) and a
261
+ # parse error can echo it back, so on failure log a generic message
262
+ # WITHOUT interpolating the exception, never leaking the credential.
263
+ try:
264
+ exporter = AzureMonitorLogExporter(connection_string=settings.telemetry_connection_string)
265
+ except Exception:
266
+ logger.warning("telemetry connection string is invalid; telemetry disabled")
267
+ return
268
+
269
+ provider = LoggerProvider(resource=Resource.create({"service.name": "coder-eval"}))
270
+ provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
271
+
272
+ # The SDK's LoggingHandler is deprecated in favor of a separate
273
+ # instrumentation package we don't depend on; the documented attribute
274
+ # bridge still works. Suppress the one-time warning so enabling
275
+ # telemetry doesn't print noise to a user's stderr.
276
+ with warnings.catch_warnings():
277
+ warnings.simplefilter("ignore", DeprecationWarning)
278
+ handler = LoggingHandler(level=logging.INFO, logger_provider=provider)
279
+
280
+ events_logger = logging.getLogger("coder_eval.telemetry.events")
281
+ events_logger.setLevel(logging.INFO)
282
+ events_logger.propagate = False # never reach console/file handlers
283
+ # The logger is a process-wide singleton; drop any stale handler from a
284
+ # prior init/shutdown cycle before attaching this one.
285
+ for stale in list(events_logger.handlers):
286
+ events_logger.removeHandler(stale)
287
+ events_logger.addHandler(handler)
288
+
289
+ _default_props = {
290
+ "Version": version,
291
+ "SchemaVersion": _TELEMETRY_SCHEMA_VERSION,
292
+ "SessionId": str(uuid.uuid4()),
293
+ "Source": settings.telemetry_source,
294
+ "IsCI": str(bool(os.getenv("CI"))),
295
+ # Generic product-telemetry dimensions: non-PII platform identity.
296
+ "OS": platform.system(),
297
+ "OSVersion": platform.release(),
298
+ "Arch": platform.machine(),
299
+ "PythonVersion": platform.python_version(),
300
+ }
301
+ # Best-effort: omit InstallId (rather than fail) if it can't be persisted.
302
+ install_id = _get_or_create_install_id()
303
+ if install_id:
304
+ _default_props["InstallId"] = install_id
305
+
306
+ _provider = provider
307
+ _handler = handler
308
+ _events_logger = events_logger
309
+ _initialized = True
310
+ atexit.register(shutdown_telemetry)
311
+ # Telemetry is now live — disclose it once (default-on tooling must say so).
312
+ _maybe_show_first_run_notice()
313
+ except Exception as exc:
314
+ # Fully non-fatal — telemetry must never break a run. (An unpersistable
315
+ # install id is handled earlier as best-effort: telemetry stays on and
316
+ # just omits InstallId, so it doesn't reach here.)
317
+ logger.warning("telemetry init failed: %s", exc)
318
+
319
+
320
+ def track_event(name: str, properties: dict[str, Scalar] | None = None) -> None:
321
+ """Emit a single custom event (no-op if telemetry is uninitialized).
322
+
323
+ Merges the process-level enrichment with the caller's ``properties`` and
324
+ routes the record to App Insights ``customEvents`` via the reserved
325
+ ``microsoft.custom_event.name`` attribute.
326
+ """
327
+ try:
328
+ if _events_logger is None:
329
+ return
330
+ merged = {**_default_props, **_coerce_props(properties)}
331
+ _events_logger.info(name, extra={_CUSTOM_EVENT_NAME_ATTR: name, **merged})
332
+ except Exception as exc:
333
+ logger.warning("telemetry track_event failed: %s", exc)
334
+
335
+
336
+ def track_command(name: str) -> Callable[[F], F]:
337
+ """Decorator factory wrapping a CLI command to emit a ``CoderEval.Cli.<name>`` event.
338
+
339
+ Captures ``Status`` / ``DurationMs`` / ``ErrorType`` on every exit path
340
+ (return, ``typer.Exit``, exception) and always re-raises the original
341
+ exception/exit. ``functools.wraps`` preserves ``__wrapped__`` so Typer/click
342
+ still introspect the real command signature.
343
+ """
344
+ import functools
345
+
346
+ def deco(func: F) -> F:
347
+ @functools.wraps(func)
348
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
349
+ start = time.monotonic()
350
+ status, error_type = "Succeeded", ""
351
+ try:
352
+ return func(*args, **kwargs)
353
+ except typer.Exit as exc:
354
+ if exc.exit_code: # non-zero exit code → failure
355
+ status, error_type = "Failed", "Exit"
356
+ raise
357
+ except (KeyboardInterrupt, SystemExit) as exc:
358
+ # These derive from BaseException, not Exception, so without this
359
+ # branch a Ctrl-C / sys.exit would skip both handlers and the
360
+ # finally would mis-record the aborted command as "Succeeded".
361
+ status, error_type = "Failed", type(exc).__name__
362
+ raise
363
+ except Exception as exc:
364
+ status, error_type = "Failed", type(exc).__name__
365
+ raise
366
+ finally:
367
+ track_event(
368
+ f"CoderEval.Cli.{name}",
369
+ {"Status": status, "DurationMs": int((time.monotonic() - start) * 1000), "ErrorType": error_type},
370
+ )
371
+
372
+ # functools.wraps copies func's signature onto wrapper for Typer/click,
373
+ # but the inferred type is Callable[..., Any], not the TypeVar F — the
374
+ # cast restores F so callers see the original command's type.
375
+ return wrapper # type: ignore[return-value]
376
+
377
+ return deco
378
+
379
+
380
+ def flush_telemetry(timeout_millis: int = 5000) -> None:
381
+ """Force-flush buffered events (no-op if telemetry is uninitialized)."""
382
+ try:
383
+ if _provider is None:
384
+ return
385
+ _provider.force_flush(timeout_millis)
386
+ except Exception as exc:
387
+ logger.warning("telemetry flush failed: %s", exc)
388
+
389
+
390
+ def shutdown_telemetry() -> None:
391
+ """Flush and tear down telemetry, resetting module state for a clean re-init."""
392
+ global _events_logger, _provider, _handler, _default_props, _initialized
393
+ try:
394
+ if _provider is None:
395
+ return
396
+ provider = _provider
397
+ if _events_logger is not None and _handler is not None:
398
+ _events_logger.removeHandler(_handler)
399
+ _events_logger = None
400
+ _provider = None
401
+ _handler = None
402
+ _default_props = {}
403
+ _initialized = False
404
+ provider.force_flush()
405
+ provider.shutdown()
406
+ except Exception as exc:
407
+ logger.warning("telemetry shutdown failed: %s", exc)