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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
coder_eval/telemetry.py
ADDED
|
@@ -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)
|