Skip to content

Commit 502fbad

Browse files
committed
Ship the error message with failure telemetry events
Failure events sent to the Datadog logs intake now carry the reserved error.message attribute alongside error.kind: the clean CLIError message (or str(exc) for unexpected exceptions), capped at 500 chars. Stack traces are still omitted, and a bare typer.Exit failure stays kind-only. https://claude.ai/code/session_0139pycafLLahbQiHsjP8kKX
1 parent b79785d commit 502fbad

3 files changed

Lines changed: 88 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `ag
180180
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
181181
- **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
182182
- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`.
183-
- **`telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS — never args, paths, or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command.
183+
- **`telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS, and on failure the error message capped at 500 chars — never args or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command.
184184
- **`commands/setup.py`**`assembly setup install/status/remove` wires a coding agent up to AssemblyAI by installing three artifacts: the `assemblyai-docs` docs MCP (via `claude mcp add`), the AssemblyAI skill (via `npx skills add`), and the bundled `aai-cli` skill (copied out of the wheel, no network). Missing `claude`/`npx` is reported and skipped, not an error. The presence probes (docs MCP registered, skills on disk) live in `aai_cli/coding_agent.py` so `assembly doctor`'s coding-agent check can share them — command modules are import-linter-independent, so neither command may import the other.
185185

186186
## Conventions

aai_cli/telemetry.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Anonymous usage telemetry, modeled on the Supabase CLI's design.
22
33
One allow-listed event per command run (command path, outcome, duration — never
4-
arguments, file paths, ids, or account data) is shipped to the Datadog logs
4+
arguments, ids, or account data; a failure also carries the error message,
5+
capped at 500 chars) is shipped to the Datadog logs
56
intake using a write-only *client* token (``pub…``), the credential class
67
Datadog designs to be embedded in client apps. ``SHIPPED_CLIENT_TOKEN`` carries
78
it (it is public by design — never put an API key there);
@@ -47,6 +48,10 @@
4748

4849
_SEND_TIMEOUT_SECONDS = 5.0
4950

51+
# Cap on the error message shipped with a failure event: enough for any CLI
52+
# error line, while bounding the payload if an upstream message embeds a body.
53+
_ERROR_MESSAGE_MAX_CHARS = 500
54+
5055

5156
def client_token() -> str:
5257
"""The write-only intake token: env override first, then the shipped one."""
@@ -75,7 +80,12 @@ def is_enabled() -> bool:
7580

7681

7782
def build_event(
78-
command: str, *, outcome: str, exit_code: int, duration_ms: int
83+
command: str,
84+
*,
85+
outcome: str,
86+
exit_code: int,
87+
duration_ms: int,
88+
error_message: str | None = None,
7989
) -> dict[str, object]:
8090
"""One invocation event, shaped for the Datadog logs intake.
8191
@@ -85,10 +95,11 @@ def build_event(
8595
hostname ever rides along.
8696
8797
A failure additionally sets ``status: error`` and the reserved
88-
``error.kind`` so the event feeds Datadog **Error Tracking** (issue
89-
grouping), not just log search. ``error.kind`` reuses the anonymous
90-
``outcome`` (the ``CLIError.error_type``) — the error *message* and stack
91-
trace are deliberately omitted, so no free text or PII ever rides along.
98+
``error.kind``/``error.message`` so the event feeds Datadog **Error
99+
Tracking** (issue grouping), not just log search. ``error.kind`` reuses the
100+
anonymous ``outcome`` (the ``CLIError.error_type``); ``error.message`` is
101+
the one-line message the user saw (capped at ``_ERROR_MESSAGE_MAX_CHARS``).
102+
Stack traces are still deliberately omitted.
92103
"""
93104
succeeded = outcome == "success"
94105
event: dict[str, object] = {
@@ -108,7 +119,10 @@ def build_event(
108119
"device_id": config.get_device_id(),
109120
}
110121
if not succeeded:
111-
event["error"] = {"kind": outcome}
122+
error: dict[str, object] = {"kind": outcome}
123+
if error_message:
124+
error["message"] = error_message[:_ERROR_MESSAGE_MAX_CHARS]
125+
event["error"] = error
112126
return event
113127

114128

@@ -154,11 +168,24 @@ def flush_payload(raw: str) -> None:
154168
)
155169

156170

157-
def _safe_dispatch(command: str, started: float, *, outcome: str, exit_code: int) -> None:
171+
def _safe_dispatch(
172+
command: str,
173+
started: float,
174+
*,
175+
outcome: str,
176+
exit_code: int,
177+
error_message: str | None = None,
178+
) -> None:
158179
duration_ms = int((time.monotonic() - started) * 1000)
159180
try:
160181
dispatch(
161-
build_event(command, outcome=outcome, exit_code=exit_code, duration_ms=duration_ms)
182+
build_event(
183+
command,
184+
outcome=outcome,
185+
exit_code=exit_code,
186+
duration_ms=duration_ms,
187+
error_message=error_message,
188+
)
162189
)
163190
except (OSError, CLIError):
164191
# Best-effort by contract: a config/spawn failure while *recording* a command
@@ -182,14 +209,22 @@ def track(command: str) -> Generator[None]:
182209
try:
183210
yield
184211
except CLIError as err:
185-
_safe_dispatch(command, started, outcome=err.error_type, exit_code=err.exit_code)
212+
_safe_dispatch(
213+
command,
214+
started,
215+
outcome=err.error_type,
216+
exit_code=err.exit_code,
217+
error_message=err.message,
218+
)
186219
raise
187220
except typer.Exit as exc:
188221
code = exc.exit_code
189222
outcome = "success" if code == 0 else "error"
190223
_safe_dispatch(command, started, outcome=outcome, exit_code=code)
191224
raise
192-
except BaseException:
193-
_safe_dispatch(command, started, outcome="internal_error", exit_code=1)
225+
except BaseException as exc:
226+
_safe_dispatch(
227+
command, started, outcome="internal_error", exit_code=1, error_message=str(exc)
228+
)
194229
raise
195230
_safe_dispatch(command, started, outcome="success", exit_code=0)

tests/test_telemetry.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,45 @@ def test_build_event_failure_feeds_error_tracking(monkeypatch):
142142
assert event["outcome"] == "api_error"
143143
assert event["exit_code"] == 1
144144
# status:error + the reserved error.kind are what promote it into Error Tracking;
145-
# error.kind mirrors the anonymous outcome, and no message/stack ever rides along.
145+
# error.kind mirrors the anonymous outcome. No message was provided, so none rides along.
146146
assert event["status"] == "error"
147147
assert event["error"] == {"kind": "api_error"}
148148

149149

150+
def test_build_event_failure_carries_error_message():
151+
event = telemetry.build_event(
152+
"aai transcribe",
153+
outcome="api_error",
154+
exit_code=1,
155+
duration_ms=5,
156+
error_message="Audio file not found: clip.wav",
157+
)
158+
# error.message is the reserved attribute Error Tracking groups/displays on.
159+
assert event["error"] == {"kind": "api_error", "message": "Audio file not found: clip.wav"}
160+
161+
162+
def test_build_event_error_message_capped_at_500_chars():
163+
# Exactly at the cap: untouched.
164+
exact = "y" * 500
165+
event = telemetry.build_event(
166+
"aai stream", outcome="api_error", exit_code=1, duration_ms=5, error_message=exact
167+
)
168+
assert event["error"] == {"kind": "api_error", "message": exact}
169+
# One over: truncated to exactly the cap.
170+
event = telemetry.build_event(
171+
"aai stream", outcome="api_error", exit_code=1, duration_ms=5, error_message="x" * 501
172+
)
173+
assert event["error"] == {"kind": "api_error", "message": "x" * 500}
174+
175+
176+
def test_build_event_blank_error_message_is_omitted():
177+
# str(exc) can be "" (e.g. RuntimeError()); don't ship an empty message field.
178+
event = telemetry.build_event(
179+
"aai stream", outcome="internal_error", exit_code=1, duration_ms=5, error_message=""
180+
)
181+
assert event["error"] == {"kind": "internal_error"}
182+
183+
150184
# --- dispatch (detached flusher handoff) ------------------------------------
151185

152186

@@ -279,6 +313,8 @@ def test_track_cli_error_keeps_error_type_and_reraises(events):
279313
(event,) = events
280314
assert event["outcome"] == "usage_error"
281315
assert event["exit_code"] == 2
316+
# The clean CLIError message the user saw rides along for Error Tracking.
317+
assert event["error"] == {"kind": "usage_error", "message": "bad flag"}
282318

283319

284320
@pytest.mark.parametrize(
@@ -290,6 +326,8 @@ def test_track_typer_exit_maps_code(events, code, outcome):
290326
(event,) = events
291327
assert event["outcome"] == outcome
292328
assert event["exit_code"] == code
329+
# A bare typer.Exit carries no message, so the failure event has only the kind.
330+
assert event.get("error") == ({"kind": "error"} if code else None)
293331

294332

295333
def test_track_unexpected_exception_is_internal_error(events):
@@ -298,6 +336,7 @@ def test_track_unexpected_exception_is_internal_error(events):
298336
(event,) = events
299337
assert event["outcome"] == "internal_error"
300338
assert event["exit_code"] == 1
339+
assert event["error"] == {"kind": "internal_error", "message": "boom"}
301340

302341

303342
@pytest.mark.parametrize("exc", [OSError("spawn failed"), CLIError("corrupt config")])

0 commit comments

Comments
 (0)